API Reference

Complete reference for the img.pro REST API. All requests go to https://api.img.pro/v1. Every endpoint returns the Image object.

Authenticate every request with a Bearer token — Authorization: Bearer YOUR_API_KEY for your own account, or a machine secret plus an X-Img-User header when acting for an app’s users. The create endpoint also works with no token (anonymous). Authentication covers all three modes and where to get the credential; the endpoints below are the same regardless of which you use.

POST /v1/images

Upload an image file as multipart/form-data. Returns the Image object; url in the response is the live CDN link, ready to embed or transform.

file File required
The image file. Supported: JPEG, PNG, GIF, WebP, AVIF, HEIC, SVG, BMP, ICO. Max 70 MB (10 MB for SVG). The stored filename comes from the multipart file part.
caption string
Free-text caption / description for the image. Up to 5,000 characters; longer is rejected with 422.
published_at string
Publish date: a unix timestamp or ISO-8601 date like 2024-06-01. Backdate it to sort the image under the photo’s date. Omit to default to upload time.
ttl string
Time-to-live: seconds (e.g. 3600) or a duration (5m90d). Omit for permanent storage.
metadata object
Your attribution fields (author, license, …) as a nested string→string map, identical to the response — the only channel for custom fields. On multipart, JSON-encode it into a single metadata form field. See Custom Fields.
labels object
Your selector labels (state, kind, …) as a nested string→string map — the queryable sibling of metadata, filterable on the list with ?label[key]=value. On multipart, JSON-encode it into a single labels form field. See Labels.

Custom fields are returned nested under metadata, so they can never collide with a built-in field. Send them the same way — in the nested metadata object (JSON-encoded on multipart). An unrecognized top-level field is rejected with 422, never silently stored. See Custom Fields.

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@photo.jpg" \
  -F "caption=Hero shot from launch day"
Response
json
{
  "id": "abc12345",
  "object": "image",
  "url": "https://src.img.pro/4j2/abc12345.jpg",
  "page_url": "https://img.pro/abc12345",
  "sizes": {
    "small":  { "url": "https://src.img.pro/4j2/abc12345.jpg?size=s", "width": 426,  "height": 320 },
    "medium": { "url": "https://src.img.pro/4j2/abc12345.jpg?size=m", "width": 853,  "height": 640 },
    "large":  { "url": "https://src.img.pro/4j2/abc12345.jpg?size=l", "width": 1440, "height": 1080 }
  },
  "filename": "hero-shot.jpg",
  "format": "jpg",
  "width": 4000,
  "height": 3000,
  "bytes": 245678,
  "transformable": true,
  "status": "ready",
  "public": true,
  "published_at": "2024-01-01T00:00:00Z",
  "expires_at": null,
  "created_at": "2024-01-01T00:00:00Z",
  "caption": "Hero shot from launch day",
  "metadata": {},
  "labels": {},
  "nsfw": false
}

Most uploads return status: "ready" immediately. Formats that need conversion (HEIC, for example) return status: "processing" with null dimensions; poll GET /v1/images/:id until ready.

Anonymous uploads

The same request works without a Bearer token:

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -F "file=@photo.jpg"

The response is the same Image object, with three deltas from an authenticated create: expires_at is always set (anonymous uploads have a shorter retention — they expire after 30 days), the per-file size cap is smaller (20 MB), and the endpoint is rate-limited. A signup nudge also rides the X-Img-Action response header (see Response headers), never the body:

Response
json
{
  "id": "abc12345",
  "object": "image",
  "expires_at": "2024-01-31T00:00:00Z"
  // (plus the standard Image fields)
}

Import from a URL

Send Content-Type: application/json with a url instead of a multipart file, and the server fetches the image for you (it must be publicly accessible; 30s timeout). All other fields from the table above work as JSON keys, and custom fields go in a nested metadata object (see Custom Fields). The filename is derived from the URL path, and metadata.source_url is set automatically to the imported URL.

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/image.jpg",
    "caption": "Imported from example.com"
  }'

Idempotent retries

To make a create safe to retry, send an Idempotency-Key header with a unique value of your choosing:

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: order-58213-hero" \
  -F "file=@photo.jpg"

A retry with the same key and body returns the original response (flagged by the Idempotent-Replayed response header — see Response headers) instead of creating a duplicate. The same key with a different body returns 409 idempotency_key_conflict, and a retry while the original is still processing returns 409 idempotency_key_in_progress (back off per the Retry-After response header). Keys are kept for 24 hours.

GET /v1/images/:id

Get a single image. Public images can be fetched without authentication.

Request
bash
curl "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY"

Returns the Image object. A blocked image returns 403 media_blocked and a failed image returns 422 media_failed; see Lifecycle.

PATCH /v1/images/:id

Update editable fields. The editable structural fields are caption, public, ttl, and published_at; an omitted field is left unchanged. Your own fields go in the nested metadata (attribution) and labels (selectors) objects — the same shapes the response returns, so you can GET an image, edit either map, and PATCH it straight back. A null value inside metadata or labels deletes that key; an unrecognized top-level field is rejected with 422 (see Custom Fields and Labels). A handful of upload-time fields are fixed once at upload and are off-limits on PATCH — sending nsfw, tool, defaults, or filename returns 422.

caption string
Update caption (up to 5,000 characters). Send empty string or null to clear.
public boolean
Flip the media’s public/private state. true / 1 / "true" = public; false / 0 / "false" = private.
ttl string
New TTL (e.g. 7d) or null to make permanent.
published_at string
Publish date: a unix timestamp or ISO-8601 date like 2024-06-01. Backdate to re-sort the item. null returns 422; every image keeps a publish date.
metadata object
Nested string→string map of your attribution fields (canonical — mirrors the response). Merged with existing metadata; a null value removes a key. See Custom Fields.
labels object
Nested string→string map of your selector labels (mirrors the response). Merged with existing labels; a null value removes a key. See Labels.
Request
bash
curl -X PATCH "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"caption": "Updated caption", "public": true, "metadata": {"alt_text": "A vivid sunset", "camera": null}}'

Returns the updated Image object.

DELETE /v1/images/:id

Permanently delete an image and all its CDN variants. Returns a tombstone confirming the deletion: the id and object, plus deleted: true. Deleting an already-deleted image returns 404.

Request
bash
curl -X DELETE "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
json
{
  "id": "abc12345",
  "object": "image",
  "deleted": true
}

GET /v1/images

List images with cursor-based pagination (cursor only, no offset). Lists carry only servable media: blocked and failed images are excluded (see Lifecycle).

ids string
Comma-separated IDs to fetch specific items. Cannot be combined with label[…] filters.
label[key] string
Filter by a label: ?label[state]=pending. A comma-separated value is IN (?label[state]=pending,liked matches either); distinct keys AND together. At most 50 value alternatives across all keys. A key you never set matches nothing (an empty page, not an error). Keep the same filter across every page of one walk — next_url carries it forward for you.
label[!key] (flag)
Match media that has no key label at all (the Kubernetes !key / does-not-exist selector). Takes no value. This is how you reach media created outside your app — a value filter can’t, since an absent key never matches a value. Composes with value filters: ?label[kind]=upload&label[!state]. See Labels.
limit integer default 50
1–100.
cursor string
Opaque pagination cursor from the previous response.
Request
bash
curl "https://api.img.pro/v1/images?limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

# filtered by label (see Labels below)
curl "https://api.img.pro/v1/images?label%5Bstate%5D=pending&limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
json
{
  "object": "list",
  "data": [
    {
      "id": "abc12345",
      "object": "image",
      "url": "https://src.img.pro/4j2/abc12345.jpg",
      "page_url": "https://img.pro/abc12345",
      "sizes": {
        "small":  { "url": "https://src.img.pro/4j2/abc12345.jpg?size=s", "width": 426,  "height": 320 },
        "medium": { "url": "https://src.img.pro/4j2/abc12345.jpg?size=m", "width": 853,  "height": 640 },
        "large":  { "url": "https://src.img.pro/4j2/abc12345.jpg?size=l", "width": 1440, "height": 1080 }
      },
      "filename": "hero-shot.jpg",
      "format": "jpg",
      "width": 4000,
      "height": 3000,
      "bytes": 245678,
      "transformable": true,
      "status": "ready",
      "public": true,
      "published_at": "2024-01-01T00:00:00Z",
      "expires_at": null,
      "created_at": "2024-01-01T00:00:00Z",
      "caption": "Hero shot from launch day",
      "metadata": {},
      "labels": {},
      "nsfw": false
    }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "1704067200_42",
    "next_url": "https://api.img.pro/v1/images?cursor=1704067200_42&limit=20"
  }
}

The pagination object holds has_more, the opaque next_cursor, and a ready-to-call next_url for the next page. On the final page all three are terminal: has_more: false, next_cursor: null, next_url: null.

PATCH /v1/images/batch

Update editable fields on up to 100 items at once (more than 100 ids returns 422). Same field restrictions as the single-item PATCH; caption, public, ttl, and published_at apply uniformly to every id in the batch, and metadata / labels merge into each item.

ids string[] required
Array of media IDs (max 100).
caption string
Same caption applied to every item (empty string or null clears).
public boolean
Flip every item’s public/private state in one call.
ttl string
Same TTL applied to every item (e.g. 7d) or null to make all permanent.
metadata object
Nested string→string map merged into every item’s attribution metadata. A null value removes that key on each item.
labels object
Nested string→string map merged into every item’s selector labels. A null value removes that key on each item. See Labels.
Request
bash
curl -X PATCH "https://api.img.pro/v1/images/batch" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["abc12345", "def45678"], "ttl": "7d", "public": false}'
Response
json
{
  "object": "batch_result",
  "data": [
    { "id": "abc12345", "object": "image", "url": "https://src.img.pro/4j2/abc12345.jpg", "public": false },
    { "id": "def45678", "object": "image", "url": "https://src.img.pro/4j2/def45678.jpg", "public": false }
  ],
  "errors": []
}

Every batch response is one batch_result envelope. data holds one entry per successfully updated item, each a complete Image object (abbreviated above for space). errors holds one entry per item that failed.

When some items are skipped (a moderation-locked image, for example) the call returns HTTP 207: those ids appear in errors, each with a nested error object (a coarse type, a specific code, and a human-readable message — the same shape detailed under Errors below), and only the successful items appear in data.

Response
json
{
  "object": "batch_result",
  "data": [
    { "id": "abc12345", "object": "image", "url": "https://src.img.pro/4j2/abc12345.jpg", "public": false }
  ],
  "errors": [
    { "id": "def45678", "error": { "type": "permission_error", "code": "media_locked", "message": "Media cannot be modified" } }
  ]
}

DELETE /v1/images/batch

Delete up to 100 items by ID (more than 100 ids returns 422). Every requested id is reported back with a tombstone in data.

ids string[] required
Array of image IDs to delete (max 100).
Request
bash
curl -X DELETE "https://api.img.pro/v1/images/batch" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["abc12345", "def45678"]}'
Response
json
{
  "object": "batch_result",
  "data": [
    { "id": "abc12345", "object": "image", "deleted": true },
    { "id": "def45678", "object": "image", "deleted": true }
  ],
  "errors": []
}

Same batch_result envelope as batch update; here data holds a tombstone per requested id. Deletion is idempotent: every id reports deleted: true, whether it was removed now or was already gone (matching the single DELETE /v1/images/:id).

GET /v1/usage

Current quota and usage statistics.

Request
bash
curl "https://api.img.pro/v1/usage" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
json
{
  "object": "usage",
  "monthly": {
    "uploads": 42,
    "uploads_limit": 100,
    "uploads_remaining": 58,
    "resets_at": "2024-03-01T00:00:00Z"
  },
  "totals": {
    "images_stored": 142,
    "storage_used_bytes": 52428800,
    "storage_limit_bytes": 1073741824,
    "storage_remaining_bytes": 1021313024
  },
  "plan": "free"
}

Response headers

Every authenticated response includes quota information in headers:

X-Monthly-Uploads-Used
Uploads used this billing period.
X-Monthly-Uploads-Limit
Monthly upload limit.
X-Monthly-Uploads-Remaining
Uploads remaining.
X-Storage-Used
Storage used, in bytes.
X-Storage-Limit
Storage limit, in bytes.
X-Storage-Remaining
Storage remaining, in bytes.

Depending on the endpoint and the request, you may also see these headers:

X-Img-Action
JSON signup nudge on anonymous creates (the nudge never appears in the body).
X-RateLimit-Limit
Request limit for the current window (plus -Daily variant) on anonymous flows.
X-RateLimit-Remaining
Requests remaining in the window (plus -Daily variant) on anonymous flows.
X-RateLimit-Reset
When the window resets (plus -Daily variant) on anonymous flows.
Retry-After
Back-off seconds on 429 and idempotency_key_in_progress.
Idempotent-Replayed
true when a create was replayed from an earlier Idempotency-Key.
X-Processing-Time
Server processing time in milliseconds, set on every create response.

The X-Img-Action value is a JSON object, e.g. {"type":"signup","url":"https://img.pro/auth/register","message":"Sign up for permanent storage, larger files, and higher limits","label":"Create Account"}. All headers above are exposed for cross-origin reads.

Custom Fields

Your own fields live in the nested metadata object — the single channel for custom data, on input and output alike. The request shape mirrors the response, so the round-trip is symmetric: GET an image, edit metadata, and send it straight back. A custom field sent at the top level is rejected with 422, never silently stored.

Set fields

On a JSON request (URL import or PATCH), send a nested metadata object:

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/photo.jpg",
    "caption": "Sunset over the Pacific",
    "metadata": { "author": "Jane Doe", "license": "cc-by-4.0" }
  }'

On a multipart upload a form field can’t hold a nested object, so JSON-encode your fields into a single metadata form field. Custom fields sent as bare top-level form fields are rejected with 422:

Request
bash
curl -X POST "https://api.img.pro/v1/images" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@photo.jpg" \
  -F "caption=Sunset over the Pacific" \
  -F 'metadata={"author":"Jane Doe","license":"cc-by-4.0"}'

Read fields

Custom fields come back nested under metadata. What you send is what you get back:

Response
json
{
  "id": "abc12345",
  "object": "image",
  "url": "https://src.img.pro/4j2/abc12345.jpg",
  "page_url": "https://img.pro/abc12345",
  "format": "jpg",
  "status": "ready",
  "caption": "Sunset over the Pacific",
  "created_at": "2024-01-01T00:00:00Z",
  "metadata": {
    "author": "Jane Doe",
    "license": "cc-by-4.0"
  }
}

Update fields

PATCH the nested metadata object; it merges with the existing map. Set a key to null to remove it (keys you don’t mention are left unchanged):

Request
bash
curl -X PATCH "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"metadata": {"alt_text": "A vivid sunset", "camera": null}}'

Field-name safety

The Image object’s own field names round-trip safely: you can GET a response, tweak it, and POST or PATCH it back, and those keys (id, url, format, status, …) are accepted-and-ignored rather than rejected. Any other unrecognized top-level field is rejected with 422 — custom fields belong in the nested metadata object. Internal routing markers (_team_id, _csrf, and the rest of the _-prefixed set) are stripped before storage.

metadata is a flat string→string map with limits: ≤ 50 keys, each key ≤ 64 chars and value ≤ 1024 chars. Exceeding any returns 422 validation_error with the offending field in details.

Upload-time fields (nsfw, tool, defaults, filename) are fixed at upload and rejected on PATCH; see PATCH /v1/images/:id for the editable-field rule.

Labels

metadata describes an image; labels select images. When your app needs to fetch images by machine state — a review queue, a kind, a workflow step — write a label and filter the list on it, instead of listing everything and filtering client-side.

Write labels

Labels are written exactly like metadata: a nested object on upload or PATCH, with merge semantics — keys you don’t send are preserved, and a null value clears one key. On multipart, JSON-encode them into a single labels form field.

Request
bash
curl -X PATCH "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"labels": {"state": "liked"}}'          # merges; other labels untouched

# clear one key
curl -X PATCH "https://api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"labels": {"state": null}}'

Filter a list

Add label[key]=value params to GET /v1/images. A comma-separated value matches any of the alternatives (IN); distinct keys must all match (AND):

Request
bash
# state == pending
curl "https://api.img.pro/v1/images?label%5Bstate%5D=pending" \
  -H "Authorization: Bearer YOUR_API_KEY"

# state IN (pending, liked)  — CSV is OR
curl "https://api.img.pro/v1/images?label%5Bstate%5D=pending,liked" \
  -H "Authorization: Bearer YOUR_API_KEY"

# state == pending AND kind == generated  — distinct keys AND
curl "https://api.img.pro/v1/images?label%5Bstate%5D=pending&label%5Bkind%5D=generated" \
  -H "Authorization: Bearer YOUR_API_KEY"

Find media without a label

Prefix the key with ! to match media that has no such label — the Kubernetes !key (does-not-exist) selector. It takes no value. This is the only way to reach media created outside your app (an img.pro web or plain-API upload your app never labeled): a value filter can’t, because an absent key never matches a value. It composes with value filters, so “uploads I haven’t triaged yet” is one call:

Request
bash
# media with no "state" label at all
curl "https://api.img.pro/v1/images?label%5B%21state%5D" \
  -H "Authorization: Bearer YOUR_API_KEY"

# kind == upload AND no "state" label  — the "unlabeled inbox"
curl "https://api.img.pro/v1/images?label%5Bkind%5D=upload&label%5B%21state%5D" \
  -H "Authorization: Bearer YOUR_API_KEY"

Value filters are equality + IN. Filtering a value on a key you never set returns an empty page, not an error. A filter can carry at most 50 value alternatives across all keys and 20 keys total (label[!key] counts as a key, carries no value), and cannot be combined with ids. Keep the same filter across every page of one walk — the cursor is a position in that filtered ordering, and next_url carries your filter forward automatically.

Bounds

labels is deliberately tighter than metadata — the bound is what makes it safe to promise a filter contract on: at most 20 labels per image, keys matching ^[a-z0-9_.-]{1,64}$, values ≤ 128 chars with no commas (the filter’s IN separator) and no leading/trailing whitespace (the filter trims values). Violations return 422 validation_error naming the offending key. Keep rich, human-facing data in metadata; keep short machine selectors in labels.

Errors

Every error is one nested error object: a coarse error.type, a specific error.code, a human-readable error.message, and (conditionally) error.action, error.details, or error.usage (a quota snapshot on quota_exceeded). Branch on type or switch on code; see the Error Reference for full details.

unauthorized 401
Missing or invalid API key.
forbidden 403
Key lacks the required ability (read / write).
quota_exceeded 403
Upload or storage limit reached.
media_locked 403
Image is moderation-locked and can’t be modified.
media_blocked 403
Image is blocked and can’t be retrieved.
not_found 404
Media item not found.
idempotency_key_conflict 409
Idempotency-Key reused with a different body.
idempotency_key_in_progress 409
Same Idempotency-Key still processing; wait for Retry-After.
validation_error 422
Invalid parameters (per-field errors in details).
media_failed 422
Image processing failed; the message explains why.
rate_limited 429
Too many requests.
upload_failed 500
Server error during upload.
import_failed 500
Server error during import.
fetch_failed 502
Could not fetch the import URL.