API Reference

Complete reference for the img.pro REST API. All requests go to https://test.api.img.pro/v1. Create, get, and update return the Image object; list and batch-update endpoints wrap Image objects in stable envelopes, while batch delete wraps deletion tombstones.

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.
public boolean / integer / string
Visibility. JSON accepts booleans true / false, numbers 1 / 0, and strings "true" / "false" / "1" / "0"; multipart accepts those four string forms. Letter forms are case-insensitive and surrounding whitespace is ignored. Null, blank strings, and every other value return 422. Omit to take the workspace default — public in an ordinary img.pro workspace, private in an app-provisioned one. Private hides the page_url viewer page and the unauthenticated single GET; your url and sizes CDN links keep serving either way, and private images still appear in your own list. Anonymous uploads are always public and reject every false form.
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://test.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://test.src.img.pro/4j2/abc12345.jpg",
  "page_url": "https://test.img.pro/abc12345",
  "sizes": {
    "small":  { "url": "https://test.src.img.pro/4j2/abc12345.jpg?size=s", "width": 426,  "height": 320 },
    "medium": { "url": "https://test.src.img.pro/4j2/abc12345.jpg?size=m", "width": 853,  "height": 640 },
    "large":  { "url": "https://test.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://test.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://test.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://test.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://test.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. Use if_labels when the update is a state transition: every supplied label must still match at commit time or nothing changes and the API returns 409 state_conflict. 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 / integer / string
Flip the media’s public/private state. Accepts booleans true / false, numbers 1 / 0, and strings "true" / "false" / "1" / "0". Letter forms are case-insensitive and surrounding whitespace is ignored; null, blank strings, and every other value return 422.
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.
if_labels object
Optional atomic precondition. Every supplied label must still equal its string value when the update commits; a mismatch returns 409 state_conflict and applies no fields. Uses the same bounds as labels; null and empty values are rejected.
Request
bash
curl -X PATCH "https://test.api.img.pro/v1/images/abc12345" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"labels": {"state": "liked"}, "if_labels": {"state": "pending"}}'

Returns the updated Image object. A failed if_labels comparison returns 409 state_conflict; reload the image before deciding whether to retry.

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://test.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), ordered by published_at newest-first — backdate an image to sort it under the photo’s date, or re-stamp published_at via PATCH to move it to the head of the list. 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://test.api.img.pro/v1/images?limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

# filtered by label (see Labels below)
curl "https://test.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://test.src.img.pro/4j2/abc12345.jpg",
      "page_url": "https://test.img.pro/abc12345",
      "sizes": {
        "small":  { "url": "https://test.src.img.pro/4j2/abc12345.jpg?size=s", "width": 426,  "height": 320 },
        "medium": { "url": "https://test.src.img.pro/4j2/abc12345.jpg?size=m", "width": 853,  "height": 640 },
        "large":  { "url": "https://test.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": "p1704067200_42",
    "next_url": "https://test.api.img.pro/v1/images?cursor=p1704067200_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. Optional if_labels applies the same atomic precondition to every item; mismatches appear as per-item state_conflict errors.

ids string[] required
Array of media IDs (max 100).
caption string
Same caption applied to every item (empty string or null clears).
public boolean / integer / string
Flip every item’s public/private state in one call. Accepts booleans true / false, numbers 1 / 0, and strings "true" / "false" / "1" / "0". Letter forms are case-insensitive and surrounding whitespace is ignored; null, blank strings, and every other value return 422.
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.
if_labels object
Optional atomic precondition applied to every item. Mismatches skip only that item and report state_conflict.
Request
bash
curl -X PATCH "https://test.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://test.src.img.pro/4j2/abc12345.jpg", "public": false },
    { "id": "def45678", "object": "image", "url": "https://test.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://test.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://test.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. Batch deletion is idempotent: every id reports deleted: true, whether it was removed now or was already gone. This deliberately differs from a single DELETE /v1/images/:id, which returns 404 for an already-missing image.

GET /v1/usage

Current quota and usage statistics.

Request
bash
curl "https://test.api.img.pro/v1/usage" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
json
{
  "object": "usage",
  "monthly": {
    "uploads": 42,
    "uploads_limit": 1000,
    "uploads_remaining": 958,
    "resets_at": "2024-03-01T00:00:00Z"
  },
  "totals": {
    "images_stored": 142,
    "storage_used_bytes": 52428800,
    "storage_limit_bytes": 10737418240,
    "storage_remaining_bytes": 10684989440
  },
  "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 hourly window resets, on anonymous flows (no -Daily variant — the daily window exposes only Limit/Remaining).
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://test.img.pro/auth/register","message":"Sign up for permanent storage, larger files (70 MB), 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://test.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://test.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://test.src.img.pro/4j2/abc12345.jpg",
  "page_url": "https://test.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://test.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.

One caveat on that round-trip: caption, public, and published_at are Image field names that are also editable, so replaying a GET body into a POST or PATCH writes them rather than ignoring them. That symmetry is deliberate, but it means a copied response carries the source image’s visibility — drop public from the body if you want the destination workspace’s default instead.

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://test.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://test.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://test.api.img.pro/v1/images?label%5Bstate%5D=pending" \
  -H "Authorization: Bearer YOUR_API_KEY"

# state IN (pending, liked)  — CSV is OR
curl "https://test.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://test.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://test.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://test.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.
state_conflict 409
An if_labels precondition no longer matches; reload before deciding whether to retry.
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.