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:
- Read
IMGD_KEYfrom the environment. - Read one accepted image file.
- Build a
multipart/form-datarequest withreqwest. - Accept either a
202new upload or a200deduplicated upload. - Decode success JSON into a Rust structure.
- Decode API errors and report the
fixvalue. - 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_KEYin 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 for a PHP cURL version.
- Upload an image with Ruby for a standard-library version.
- Resize and convert the public image after the upload.
For an agent, use the integration procedure. For human setup, use the start page. Read the service evaluation before you select a host.