Skip to content
Select theme

How to POST JSON to an API

Short answer: Give the payload a struct that derives serde::Serialize, send it with reqwest::Client::new().post(url).json(&payload).send().await?, then read response.status(). reqwest hands you the status code as data, not as an error — a 404 comes back as Ok with a 404 status.

This picks up the fetch project from How to make a GET request. Its [dependencies] section changes one line and grows one:

Cargo.toml
[dependencies]
reqwest = { version = "0.13.5", features = ["json"] }
tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0.229", features = ["derive"] }

reqwest’s json feature is not in the default set, so it has to be named. It brings in serde_json and the serde traits that the request builder needs. serde’s own derive feature is what puts #[derive(Serialize)] on the table.

The whole program, src/main.rs:

src/main.rs
use serde::Serialize;
#[derive(Serialize)]
struct Person {
name: String,
age: u32,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let person = Person {
name: "Ada".to_string(),
age: 36,
};
let response = reqwest::Client::new()
.post("https://httpbin.org/post")
.json(&person)
.send()
.await?;
println!("{}", response.status());
Ok(())
}

Run it with cargo run (cargo’s Compiling and Finished lines are not shown here). httpbin.org answers a POST to /post with a 200 and echoes the body back, so the output is stable:

200 OK

.json(&person) serialises the struct and sets the body, and it sets Content-Type: application/json unless that header is already set. Everything before send() is assembly: the builder holds the method, the URL and the body, and nothing reaches a socket until the future is awaited.

There is no free reqwest::post to pair with the reqwest::get of the last article:

cargo build prints:

error[E0425]: cannot find function `post` in crate `reqwest`
--> src/main.rs:2:22
|
2 | let _ = reqwest::post("https://httpbin.org/post");
| ^^^^ not found in `reqwest`
For more information, try `rustc --explain E0425`.

Client is the way in, and it is the shape worth keeping. A client holds a connection pool internally, reusing connections and avoiding setup overhead, so one client built at the top of main and cloned where it is needed beats a fresh client per request — a clone is cheap, because the client is an Arc inside. Client::new() panics if the TLS backend cannot be initialised; Client::builder() is the fallible route when that failure is worth handling.

Success is not part of the contract. Point the same body at an endpoint that refuses it:

src/main.rs
use serde::Serialize;
#[derive(Serialize)]
struct Person {
name: String,
age: u32,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let person = Person {
name: "Ada".to_string(),
age: 36,
};
let response = reqwest::Client::new()
.post("https://httpbin.org/status/418")
.json(&person)
.send()
.await?;
println!("{}", response.status());
Ok(())
}
418 I'm a teapot

That call returned Ok. A 404, a 500 and a 418 all arrive the same way: the request completed, the server answered, and the answer is a status line you have to look at. When propagating beats inspecting, Response::error_for_status() turns 4xx and 5xx into an Err. That conversion, and reading the body of a failed response, are a later article’s subject.