---
title: "How to Upload an Image With cURL and Get a Public URL"
description: "Upload JPEG, PNG, GIF, WebP, or AVIF bytes with cURL and verify the imgd.dev public URL."
slug: "upload-image-curl-public-url"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Send a POST request to https://imgd.dev/v1/upload with the file field, then read the public URL from the JSON response."
cluster: Getting started
intent: how-to
sources:
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
  - title: "imgd.dev agent reference"
    url: "https://imgd.dev/llms.txt"
  - title: "curl manual"
    url: "https://curl.se/docs/manpage.html"
---

Upload an image with cURL by sending `POST https://imgd.dev/v1/upload` with the multipart field `file`. The JSON response contains a content hash and a public URL. New bytes return HTTP `202`. Bytes that already exist return HTTP `200` with `deduplicated: true`.

## Before you upload

imgd.dev costs **$1 per GB as a one-time storage purchase**. It has **no free tier**. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.

You need a funded account, cURL, `jq`, and an accepted image. Set the key outside the repository:

```bash
export IMGD_KEY="$(security find-generic-password -w -s imgd-dev)"
```

The command above is one macOS example. Use your existing secret manager on other systems. Do not put the key in a source file.

## Upload with multipart form data

Save this as `upload.sh`, or run it in Bash. Pass the image path as the first argument.

```bash
#!/usr/bin/env bash
set -eu

: "${IMGD_KEY:?Set IMGD_KEY in your shell.}"
IMAGE_PATH="${1:-image.png}"
BODY_FILE="$(mktemp)"
trap 'rm -f "$BODY_FILE"' EXIT

HTTP_STATUS="$(
  curl --silent --show-error \
    --output "$BODY_FILE" \
    --write-out '%{http_code}' \
    --request POST 'https://imgd.dev/v1/upload' \
    --header "Authorization: Bearer $IMGD_KEY" \
    --form "file=@$IMAGE_PATH"
)"

case "$HTTP_STATUS" in
  200|202)
    ;;
  *)
    jq . "$BODY_FILE" >&2
    exit 1
    ;;
esac

jq . "$BODY_FILE"
HASH="$(jq --exit-status --raw-output '.hash' "$BODY_FILE")"
IMAGE_URL="$(jq --exit-status --raw-output '.url' "$BODY_FILE")"
printf 'Hash: %s\nPublic URL: %s\n' "$HASH" "$IMAGE_URL"
```

A new PNG can return this JSON. The zero hash marks the value that your response supplies.

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 42187,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
```

The URL works immediately. It serves a blurred placeholder while the status is `processing`. It serves the original image after the status becomes `live`.

## Upload raw image bytes

Use raw bytes when your program already knows the correct media type. This example sends a PNG.

```bash
RAW_BODY_FILE="$(mktemp)"
RAW_HTTP_STATUS="$(
  curl --silent --show-error \
    --output "$RAW_BODY_FILE" \
    --write-out '%{http_code}' \
    --request POST 'https://imgd.dev/v1/upload' \
    --header "Authorization: Bearer $IMGD_KEY" \
    --header 'Content-Type: image/png' \
    --data-binary '@image.png'
)"

case "$RAW_HTTP_STATUS" in
  200|202)
    jq . "$RAW_BODY_FILE"
    ;;
  *)
    jq . "$RAW_BODY_FILE" >&2
    rm -f "$RAW_BODY_FILE"
    exit 1
    ;;
esac
rm -f "$RAW_BODY_FILE"
```

Set `Content-Type` to the real file type. The accepted values are `image/jpeg`, `image/png`, `image/gif`, `image/webp`, and `image/avif`.

## Verify the metadata

Use the returned hash. This request confirms ownership, status, dimensions, and alt text.

```bash
curl --fail --silent --show-error \
  "https://imgd.dev/v1/images/$HASH" \
  --header "Authorization: Bearer $IMGD_KEY" \
  | jq .
```

A live image can return this JSON:

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "live",
  "mime": "image/png",
  "bytes": 42187,
  "width": 1280,
  "height": 720,
  "alt_text": "A browser window with a completed upload message.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "image.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}
```

The status can become `live`, `review`, `blocked`, or `error`. The `review` state is terminal and does not clear by itself.

## Verify the public URL

Fetch the URL without an authorization header. A successful request returns HTTP `200` and an image content type.

```bash
PUBLIC_HTTP_STATUS="$(
  curl --silent --show-error \
    --output /dev/null \
    --write-out '%{http_code}' \
    "$IMAGE_URL"
)"

test "$PUBLIC_HTTP_STATUS" = "200"
printf 'The public URL returned HTTP %s.\n' "$PUBLIC_HTTP_STATUS"
```

A `processing` response contains a blurred JPEG placeholder. It can include `X-Imgd-Moderation: processing-blurred`. No caller needs the key to fetch this public URL.

## Recover from upload errors

Every API error has an `error` field and a `fix` field. Read and apply `fix` before another upload.

| Status | Meaning | Recovery |
| --- | --- | --- |
| `401` | The key is missing or invalid. | Check `IMGD_KEY` in the secret store. Send it only in the bearer header. |
| `402` | The account has no storage, or the quota is full. | Buy storage or delete unused images. Then send the same bytes again. |
| `413` | The image is larger than 20 MB. | Resize or compress it below 20 MB. |
| `415` | The media type is not accepted. | Convert it to JPEG, PNG, GIF, WebP, or AVIF. Send the matching type. |

Do not retry a `401`, `413`, or `415` response without a change. A network failure is different. You can safely retry the same bytes because the URL uses the SHA-256 content hash.

A typical media-type error is valid JSON:

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

## Keep the key and image safe

Keep `IMGD_KEY` in a secret manager or an untracked environment file. Never put it in a URL, command trace, source file, or log.

Inspect the image before the upload. Public storage does not protect confidential text, faces, access tokens, or customer data.

## Decide if imgd.dev fits

imgd.dev fits scripts that need a stable public image URL. It also fits safe retries and one-time storage purchases.

It does not fit private images, access control, video, PDFs, files above 20 MB, or a free trial. Read the [service evaluation guide](/evaluate.md) before you choose it.

## Result

You now have a content-addressed public URL and its metadata. The same bytes will return the same URL on a later upload.

Next, [turn a screenshot into a URL](/blog/screenshot-to-url-command-line/) or [upload with JavaScript and Node.js](/blog/upload-image-javascript-nodejs/).

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
