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.
The dependencies
Section titled “The dependencies”This picks up the fetch project from How to make a GET request. Its [dependencies] section changes one line and grows one:
[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 request
Section titled “The request”The whole program, 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.
Why a Client, and not a free function
Section titled “Why a Client, and not a free function”There is no free reqwest::post to pair with the reqwest::get of the last article:
fn main() { let _ = reqwest::post("https://httpbin.org/post");}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.
The status code is yours to read
Section titled “The status code is yours to read”Success is not part of the contract. Point the same body at an endpoint that refuses it:
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 teapotThat 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.
- How to make a GET request — the request without a body, and the runtime behind
#[tokio::main]. - How to serialize a struct to JSON — the bytes
#[derive(Serialize)]produces. - Making a real HTTP request with reqwest — the same call as one step inside a CLI tool.