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:

[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:

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.

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:

{
  "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:

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:

{
  "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:

{
  "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

For an agent, use the integration procedure. For human setup, use the start page. Read the service evaluation before you select a host.

Sources