API Reference
Complete reference for the img.pro REST API. All requests go to https://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. transformable: true requires a supported source format, at most 20 MB (20,000,000 bytes), and a successful Images probe. Larger browser-servable rasters are stored and served as-is; larger HEIC is rejected because browsers require its transformed rendition.
-
fileFile 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.
-
captionstring - Free-text caption / description for the image. Up to 5,000 characters; longer is rejected with
422. -
published_atstring - 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. -
ttlstring - Time-to-live: seconds (e.g.
3600) or a duration (5m–90d). Omit for permanent storage. -
publicboolean / integer / string - Visibility. JSON accepts booleans
true/false, numbers1/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 return422. Omit to take the workspace default — public in an ordinary img.pro workspace, private in an app-provisioned one. Private hides thepage_urlviewer page and the unauthenticated single GET; yoururlandsizesCDN links keep serving either way, and private images still appear in your own list. Anonymous uploads are always public and reject every false form. -
metadataobject - 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 singlemetadataform field. See Custom Fields.
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.
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"
{
"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. SVG sanitization and transient Images-probe failures may return status: "processing"; poll GET /v1/images/:id until ready or failed.
Anonymous uploads
The same request works without a Bearer token:
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:
{
"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.
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:
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"
For authenticated creates, a retry with the same key and body returns or recovers the original response (a cache replay is 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. If a create write is still being reconciled, 503 create_outcome_ambiguous asks you to retry that identical request. Honor Retry-After for both wait outcomes. Use a fresh globally unique key for each logical create. Idempotency state is kept for 24 hours; retry only the identical request during that window. After 24 hours, replay and duplicate prevention are not guaranteed, so do not retry the old operation or repurpose its key. If retained cache state is unavailable, a URL import may need its source URL to remain fetchable. Anonymous creates ignore this header.
GET /v1/images/:id
Get a single image. Public images can be fetched without authentication.
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. 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.
-
captionstring - Update caption (up to 5,000 characters). Send empty string or
nullto clear. -
publicboolean / integer / string - Flip the media’s public/private state. Accepts booleans
true/false, numbers1/0, and strings"true"/"false"/"1"/"0". Letter forms are case-insensitive and surrounding whitespace is ignored; null, blank strings, and every other value return422. -
ttlstring - New TTL (e.g.
7d) ornullto make permanent. -
published_atstring - Publish date: a unix timestamp or ISO-8601 date like
2024-06-01. Backdate to re-sort the item.nullreturns 422; every image keeps a publish date. -
metadataobject - Nested string→string map of your attribution fields (canonical — mirrors the response). Merged with existing metadata; a
nullvalue removes a key. See Custom Fields. -
labelsobject - Nested string→string map of your selector labels (mirrors the response). Merged with existing labels; a
nullvalue removes a key. See Labels. -
if_labelsobject - Optional atomic precondition. Every supplied label must still equal its string value when the update commits; a mismatch returns
409 state_conflictand applies no fields. Uses the same bounds aslabels; null and empty values are rejected.
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"}, "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.
curl -X DELETE "https://api.img.pro/v1/images/abc12345" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"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).
-
idsstring - 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 isIN(?label[state]=pending,likedmatches 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_urlcarries it forward for you. -
label[!key](flag) - Match media that has no
keylabel 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. -
limitinteger default 50 1–100.-
cursorstring - Opaque pagination cursor from the previous response.
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"
{
"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": "p1704067200_42",
"next_url": "https://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.
-
idsstring[] required - Array of media IDs (max 100).
-
captionstring - Same caption applied to every item (empty string or
nullclears). -
publicboolean / integer / string - Flip every item’s public/private state in one call. Accepts booleans
true/false, numbers1/0, and strings"true"/"false"/"1"/"0". Letter forms are case-insensitive and surrounding whitespace is ignored; null, blank strings, and every other value return422. -
ttlstring - Same TTL applied to every item (e.g.
7d) ornullto make all permanent. -
metadataobject - Nested string→string map merged into every item’s attribution metadata. A
nullvalue removes that key on each item. -
labelsobject - Nested string→string map merged into every item’s selector labels. A
nullvalue removes that key on each item. See Labels. -
if_labelsobject - Optional atomic precondition applied to every item. Mismatches skip only that item and report
state_conflict.
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}'
{
"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.
{
"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.
-
idsstring[] required - Array of image IDs to delete (max 100).
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"]}'
{
"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.
curl "https://api.img.pro/v1/usage" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"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
-Dailyvariant) on anonymous flows. -
X-RateLimit-Remaining - Requests remaining in the window (plus
-Dailyvariant) on anonymous flows. -
X-RateLimit-Reset - When the hourly window resets, on anonymous flows (no
-Dailyvariant — the daily window exposes only Limit/Remaining). -
Retry-After - Back-off seconds on
429,idempotency_key_in_progress, andcreate_outcome_ambiguous. -
Idempotent-Replayed truewhen a create was replayed from an earlierIdempotency-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 (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:
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:
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:
{
"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):
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 and the rest of the accepted _-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.
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):
# 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:
# 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.
-
unauthorized401 - Missing or invalid API key.
-
forbidden403 - Key lacks the required ability (read / write).
-
quota_exceeded403 - Upload or storage limit reached.
-
media_locked403 - Image is moderation-locked and can’t be modified.
-
media_blocked403 - Image is blocked and can’t be retrieved.
-
not_found404 - Media item not found.
-
workspace_unavailable409 - Destination workspace is in a destructive lifecycle transition; restore or select an active workspace before retrying.
-
state_conflict409 - An
if_labelsprecondition no longer matches; reload before deciding whether to retry. -
idempotency_key_conflict409 - Idempotency-Key reused with a different body.
-
idempotency_key_in_progress409 - Same Idempotency-Key still processing; wait for Retry-After.
-
idempotency_recovery_conflict409 - Canonical create row no longer matches a safely recoverable operation; stop automatic retries.
-
validation_error422 - Invalid parameters (per-field errors in
details). -
media_failed422 - Image processing failed; the message explains why.
-
rate_limited429 - Too many requests.
-
upload_failed500 - Server error during upload.
-
import_failed500 - Server error during import.
-
fetch_failed502 - Could not fetch the import URL.
-
create_outcome_ambiguous503 - Canonical create write is still being reconciled; retry the identical request after Retry-After.