---
title: Upload an Image With Rust and reqwest
description: Upload an image with async Rust, reqwest multipart forms, typed JSON, and status-specific recovery.
slug: upload-image-rust
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Use reqwest to send a multipart file field to the imgd.dev upload endpoint and read the public URL from typed JSON.
cluster: Languages
intent: how-to
sources:
  - title: reqwest multipart module
    url: https://docs.rs/reqwest/latest/reqwest/multipart/index.html
  - title: reqwest Response API
    url: https://docs.rs/reqwest/latest/reqwest/struct.Response.html
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Upload an Image With Rust and reqwest

Use async `reqwest` to send a multipart field named `file` to `https://imgd.dev/v1/upload`. The typed JSON response contains the image hash and public URL. The complete example also reports the server `fix` field and verifies the returned URL.

imgd.dev costs a one-time **$1 per GB** of storage. It has **no free tier**, so fund the account before the first upload. Every imgd.dev image is public to anyone who has its URL. Do not upload private, confidential, or access-controlled content.

## What the example does

The Rust program uses these steps:

1. Read `IMGD_KEY` from the environment.
2. Read one accepted image file.
3. Build a `multipart/form-data` request with `reqwest`.
4. Accept either a `202` new upload or a `200` deduplicated upload.
5. Decode success JSON into a Rust structure.
6. Decode API errors and report the `fix` value.
7. Fetch the public URL and verify an image response.

The upload limit is 20 MB. Accepted media types are JPEG, PNG, GIF, WebP, and AVIF.

## Create the runnable example

Save this `Cargo.toml` in `examples/workflows/rust/Cargo.toml`:

```toml
[package]
name = "imgd-upload-rust"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
reqwest = { version = "0.13.4", features = ["json", "multipart"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["fs", "macros", "rt-multi-thread"] }
```

Save this program in `examples/workflows/rust/src/main.rs`:

```rust
use reqwest::header::ACCEPT;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use std::env;
use std::error::Error;
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;

const UPLOAD_URL: &str = "https://imgd.dev/v1/upload";

#[derive(Debug, Deserialize)]
struct ApiError {
    error: String,
    fix: String,
}

#[derive(Debug, Deserialize, Serialize)]
struct UploadResponse {
    hash: String,
    url: String,
    status: String,
    mime: String,
    bytes: u64,
    width: Option<u32>,
    height: Option<u32>,
    alt_text: Option<String>,
    unpublish_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    deduplicated: Option<bool>,
    note: String,
}

fn required_env(name: &str) -> Result<String, io::Error> {
    env::var(name).map_err(|_| io::Error::other(format!("set the {name} environment variable")))
}

fn image_mime(path: &Path) -> Result<&'static str, io::Error> {
    let extension = path
        .extension()
        .and_then(OsStr::to_str)
        .unwrap_or_default()
        .to_ascii_lowercase();

    match extension.as_str() {
        "jpg" | "jpeg" => Ok("image/jpeg"),
        "png" => Ok("image/png"),
        "gif" => Ok("image/gif"),
        "webp" => Ok("image/webp"),
        "avif" => Ok("image/avif"),
        _ => Err(io::Error::other(
            "use a jpeg, png, gif, webp, or avif file extension",
        )),
    }
}

fn api_error(status: reqwest::StatusCode, body: &str) -> io::Error {
    match serde_json::from_str::<ApiError>(body) {
        Ok(error) => io::Error::other(format!(
            "upload failed with HTTP {} and {}: {}",
            status.as_u16(),
            error.error,
            error.fix
        )),
        Err(_) => io::Error::other(format!(
            "upload failed with HTTP {} and a malformed response body",
            status.as_u16()
        )),
    }
}

async fn upload(client: &reqwest::Client, path: &Path) -> Result<UploadResponse, Box<dyn Error>> {
    let key = required_env("IMGD_KEY")?;
    let mime = image_mime(path)?;
    let file_name = path
        .file_name()
        .and_then(OsStr::to_str)
        .ok_or_else(|| io::Error::other("the image path needs a file name"))?
        .to_owned();
    let image_bytes = tokio::fs::read(path).await?;
    let part = Part::bytes(image_bytes)
        .file_name(file_name)
        .mime_str(mime)?;
    let form = Form::new().part("file", part);

    let response = client
        .post(UPLOAD_URL)
        .bearer_auth(key)
        .multipart(form)
        .send()
        .await?;
    let status = response.status();
    let body = response.text().await?;

    if !status.is_success() {
        return Err(api_error(status, &body).into());
    }

    let upload = serde_json::from_str::<UploadResponse>(&body)
        .map_err(|error| io::Error::other(format!("invalid success JSON: {error}")))?;
    Ok(upload)
}

async fn verify(client: &reqwest::Client, upload: &UploadResponse) -> Result<(), Box<dyn Error>> {
    let response = client
        .get(&upload.url)
        .header(ACCEPT, "image/*")
        .send()
        .await?;
    let status = response.status();
    let content_type = response
        .headers()
        .get(reqwest::header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default()
        .to_owned();
    let _body = response.bytes().await?;

    if !status.is_success() || !content_type.starts_with("image/") {
        return Err(io::Error::other(format!(
            "public URL verification failed with HTTP {} and Content-Type {}",
            status.as_u16(),
            content_type
        ))
        .into());
    }

    eprintln!(
        "Verified {} with HTTP {} and Content-Type {}",
        upload.url,
        status.as_u16(),
        content_type
    );
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let path = env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .ok_or_else(|| io::Error::other("usage: cargo run -- /absolute/path/to/image.png"))?;
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(60))
        .build()?;
    let upload = upload(&client, &path).await?;
    verify(&client, &upload).await?;
    println!("{}", serde_json::to_string_pretty(&upload)?);
    Ok(())
}
```

`tokio::fs::read` closes its file after the read completes. Each `reqwest` response body is consumed or dropped on every path.

## Run the upload

Set `IMGD_KEY` outside the repository. Do not put the key in source code, a URL, or command output.

```bash
cd examples/workflows/rust
test -n "$IMGD_KEY"
cargo run -- /absolute/path/to/image.png | tee upload.json
```

A new upload returns HTTP `202`. The program prints this complete JSON shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1200,
  "height": 800,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
```

An identical upload can return HTTP `200`. That response also contains `"deduplicated": true`.

The public URL works at once. It can show a blurred image while moderation runs. The URL shows the final image after a `live` result.

## Verify the metadata

The program verifies that the public URL returns image bytes. Also verify the account metadata result:

```bash
HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .
```

A completed live result has this useful shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1200,
  "height": 800,
  "alt_text": "A terminal window that shows a successful Rust build.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "image.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}
```

A `review` result is terminal and serves a blurred image. A `blocked` result removes the bytes. Do not retry blocked bytes.

## Recover from upload errors

The example prints the server `fix` text. Use the HTTP status and stable `error` value together.

| Status | Meaning | Recovery |
| --- | --- | --- |
| `401` | `missing_api_key` or `invalid_api_key` | Set the correct `IMGD_KEY`. Do not retry with the same invalid value. |
| `402` | `payment_required` | Buy storage, then retry. There is no free tier. |
| `402` | `quota_exceeded` | Buy the suggested storage or delete unused images, then retry. |
| `413` | `file_too_large` | Reduce the file below 20 MB, then retry. |
| `415` | `unsupported_media_type` | Use JPEG, PNG, GIF, WebP, or AVIF with the matching media type. |
| `429` | `rate_limited` | Wait for the next minute, then retry. |

A normal API error has this base JSON shape:

```json
{
  "error": "unsupported_media_type",
  "fix": "send jpeg, png, gif, webp, or avif and set the matching Content-Type",
  "accepted": ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]
}
```

## Security notes

- Keep `IMGD_KEY` in an environment variable or a secret manager.
- Do not put the key in Git history, logs, URLs, or error reports.
- Keep TLS verification enabled in `reqwest`.
- Treat the returned URL as public data.
- Delete an image if you must remove its stored bytes and free quota.
- Do not upload secrets, private screenshots, or personal records.

## When imgd.dev fits

imgd.dev fits Rust tools that need a stable public image URL and simple multipart upload. Content-addressed URLs also make retries safe for identical bytes.

It does not fit private images, access control, files above 20 MB, video, or a free trial. Use another service when any of those needs is required.

## Next guides

- [Upload an image with PHP](/blog/upload-image-php/) for a PHP cURL version.
- [Upload an image with Ruby](/blog/upload-image-ruby/) for a standard-library version.
- [Resize and convert the public image](/blog/resize-convert-image-webp-avif/) after the upload.

For an agent, use the [integration procedure](/integrate.md). For human setup, use [the start page](/#start). Read the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [reqwest multipart module](https://docs.rs/reqwest/latest/reqwest/multipart/index.html)
- [reqwest Response API](https://docs.rs/reqwest/latest/reqwest/struct.Response.html)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
