# img.pro — Full API Reference > Image hosting and transformation API. Upload or import images, get a shareable viewer link plus a transform-capable direct CDN URL. Base URL: `https://api.img.pro` (API) · `https://src.img.pro` (image CDN, the host in every `url`) ## Authentication Two ways to call the API, picked by whose images you act on. Both hit the same `/v1/images` surface and get back the same Image object. - **API key** — your own account. Header `Authorization: Bearer img_live_…` (keys look like `img_live_…`). Uploads are permanent and count against your plan. Keys carry `read` (GET) and/or `write` (POST/PATCH/DELETE) abilities; calling outside a key's abilities → `403 forbidden`. Create keys at https://img.pro/keys. - **App (machine secret)** — your users' accounts. Header `Authorization: Bearer img_sk_live_…` plus `X-Img-User: `. One secret, no per-user tokens. See the App API section below. Register at https://img.pro/apps. ## Getting Started 1. Choose an auth mode above (most integrations use an API key). 2. Create an API key from the dashboard at https://img.pro/keys. 3. Create images with `POST /v1/images` — send a file or a `{"url":"…"}` JSON body. Every image has two URLs: `url` is the image itself — a direct, embeddable CDN URL (always present, every plan) that accepts transform query parameters; `page_url` is the shareable viewer page on img.pro you send to a person. `sizes` carries three ready-made responsive variants (small/medium/large). --- ## POST /v1/images — Create an Image One authenticated endpoint for both file uploads and URL imports. The server negotiates by Content-Type: `multipart/form-data` → file upload, `application/json` → URL import. **Upload a file** (multipart): ```bash curl -X POST https://api.img.pro/v1/images \ -H "Authorization: Bearer img_live_…" \ -F "file=@photo.jpg" \ -F "caption=Hero shot" ``` **Import from a URL** (JSON): ```json POST https://api.img.pro/v1/images Authorization: Bearer img_live_… Content-Type: application/json { "url": "https://example.com/photo.jpg", "caption": "Imported from example.com" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | file | binary | One of file/url | JPEG, PNG, GIF, WebP, AVIF, or supported HEVC-based HEIC. Multipart only. | | url | string | One of file/url | URL of image to import. Response Content-Type is advisory; the fetched bytes must identify an accepted image format. JSON only. | | caption | string | No | Free-text caption / description | | published_at | string | No | Publish date — a unix timestamp or ISO-8601 date. Backdate to sort under the photo's date. Omit to default to upload time. | | ttl | string | No | Seconds (min 300) or duration string ("5m", "2h", "7d", up to 90d). Omit for permanent. | | public | boolean / integer / string | No | 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 → `422`. Omit to take the workspace default: **public** in an ordinary img.pro workspace, **private** in an app-provisioned one (App API). Private hides `page_url` and the unauthenticated single GET; `url`/`sizes` keep serving and your own list still shows it. | | metadata | object | No | Custom attribution fields as a nested string→string map (e.g., `author`, `license`, `source_url`) — the only channel for custom fields. On multipart, JSON-encode it into a single `metadata` form field. A bare unknown top-level field → `422`. | | labels | object | No | Selector labels as a nested string→string map (e.g., `state`, `kind`) — the queryable sibling of `metadata`; filter lists with `?label[key]=value`. On multipart, JSON-encode it into a single `labels` form field. Bounds: ≤ 20 keys, key `^[a-z0-9_.-]{1,64}$`, value ≤ 128 chars, no commas or surrounding whitespace. | Every file must be at most 20 MB (20,000,000 bytes) and pass the Images decoder synchronously. A `.heif` filename/MIME alias is accepted only when its bytes contain supported HEVC HEIC. Size or deterministic decoder rejection returns `422 validation_error` under `details.file`; an exhausted Images service failure returns retryable `500 upload_failed` / `import_failed`. Neither creates a new Image. SVG, BMP, and ICO are not accepted. For imports, `metadata.source_url` defaults to the fetched URL when omitted and when that URL fits the metadata value limit. Callers may override it or explicitly set it to `null` to suppress provenance. Response (201): ```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, "status": "ready", "public": true, "published_at": "2024-01-01T00:00:00Z", "expires_at": null, "created_at": "2024-01-01T00:00:00Z", "caption": "Hero shot", "metadata": { "source_url": "https://example.com/photo.jpg" }, "labels": {}, "nsfw": false } ``` Retry-safety: send a fresh globally unique `Idempotency-Key` header for every logical POST create. A cache replay with the same key + body returns the original response unchanged with an `Idempotent-Replayed: true` header; the same key with a different body → `409 idempotency_key_conflict`; a retry while the first is still in flight → `409 idempotency_key_in_progress`; an indeterminate write still being reconciled → `503 create_outcome_ambiguous`. Honor `Retry-After` and retry the identical request for either wait outcome. Idempotency state is retained 24h; retry only the identical request during that window. After 24h, replay and duplicate prevention are not guaranteed, so do not retry the old operation or repurpose its key. If retained cache state is unavailable, URL-import recovery may require the source URL to remain fetchable. Field presence — every documented field is ALWAYS present (`null`/`{}`/`false` when empty); nothing is omitted: - `id`, `object` (always `"image"`), `url`, `page_url`, `sizes`, `filename`, `format`, `status`, `public`, `created_at`, `metadata` (`{}` when empty), `labels` (`{}` when empty), `nsfw` (`false` unless flagged). - `width`, `height`: `null` only for retained historical rows whose dimensions are unavailable. `bytes` is `null` only when stored byte size is unavailable. - `published_at` (never `null`; defaults to upload time — drives list order), `expires_at` (`null` = permanent), `caption` (`null` when unset). - Custom fields live under `metadata` (content) or `labels` (selectors), not at the top level — so your fields never collide with built-in ones like `url` or `public`. - Current creates are ready and transform-capable or rejected synchronously. - Blocked or failed media never appear as objects: lists exclude them, and a direct GET answers `403 media_blocked` / `422 media_failed` instead. File boundary: JPEG, PNG, GIF, WebP, AVIF, and supported HEVC HEIC must fit 20 MB (20,000,000 bytes) and pass a synchronous decoder probe. Custom metadata: send your fields in the nested `metadata` object — the only channel for custom data. On multipart, JSON-encode it: `-F 'metadata={"author":"Jane Doe","license":"cc-by-4.0"}'`. On JSON requests it's a nested object: `"metadata": { "author": "Jane Doe" }`. They come back nested under `metadata`. A custom field sent as a bare top-level field is rejected with `422` — never silently stored. Errors: - `422 validation_error` — Missing file/url, invalid format, file too large, decoder rejection, or invalid URL; inspect `error.details.file` - `403 quota_exceeded` — Monthly upload or storage quota reached (action: upgrade) - `500 upload_failed` — Server error during processing - `422/502/504 fetch_failed` — A URL import redirected to a disallowed address, could not fetch its upstream, or exceeded the 30-second deadline - `500 import_failed` — Server error during import (URL imports) --- ## GET /v1/images — List Images Authentication required. Cursor-based pagination (no offset), ordered by `published_at` newest-first — backdate or re-stamp an image's `published_at` to re-sort it. Lists carry only servable media with `status: ready`: blocked, processing, and failed rows are excluded. Request: ```bash curl https://api.img.pro/v1/images?limit=20 \ -H "Authorization: Bearer img_live_…" ``` | Param | Type | Description | |-------|------|-------------| | 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`. CSV = IN (`?label[state]=pending,liked` matches either); distinct keys AND (`?label[state]=pending&label[kind]=generated`). ≤ 50 value alternatives across all keys, 20 keys total. Filtering a value on an unset key matches nothing (empty page, not an error). Keep the same filter across a walk — `next_url` carries it forward. | | label[!key] | (flag) | Does-not-exist (k8s `!key`): match media with NO `key` label — e.g. `?label[!state]`. Takes no value. The only way to reach media created outside your app (web/plain-API uploads it never labeled), since an absent key never matches a value. Composes with value filters: `?label[kind]=upload&label[!state]`. | | cursor | string | Opaque pagination cursor from the previous response | | limit | integer | Results per page (1-100, default 50) | Response (200): an `object: "list"` envelope whose `data` is an array of Image objects (same shape as the create response), plus a `pagination` object: ```json { "object": "list", "data": [ { "id": "abc12345", "object": "image", "url": "https://src.img.pro/4j2/abc12345.jpg", "...": "rest of the Image object" } ], "pagination": { "has_more": true, "next_cursor": "p1704067200_42", "next_url": "https://api.img.pro/v1/images?cursor=p1704067200_42&limit=20" } } ``` On the last page all three pagination fields are terminal: `has_more: false`, `next_cursor: null`, `next_url: null`. --- ## GET /v1/images/:id — Get Image Details Works without authentication for public images. With auth, returns any image owned by the team. ```bash curl https://api.img.pro/v1/images/abc12345 \ -H "Authorization: Bearer img_live_…" ``` Response (200): the Image object. Errors: - `404 not_found` — Image doesn't exist or isn't accessible - `403 media_blocked` — Image was blocked by moderation (the owner's error message includes the coarse reason: content_policy, dmca, spam, or terms) - `422 media_failed` — Image processing failed permanently (message explains why) --- ## PATCH /v1/images/:id — Update Image Authentication required. Only `caption`, `public`, `published_at`, `ttl`, the nested `metadata` / `labels` objects, and the optional `if_labels` precondition are accepted. Custom fields go inside `metadata`, selector labels inside `labels` (a `null` value deletes a key in either); an unknown top-level field returns 422 — as do `nsfw`, `tool`, `defaults`, and `filename`. ```json PATCH https://api.img.pro/v1/images/abc12345 Authorization: Bearer img_live_… Content-Type: application/json { "labels": { "state": "liked" }, "if_labels": { "state": "pending" } } ``` | Field | Type | Description | |-------|------|-------------| | caption | string | Free-text caption. Empty string or null clears it. | | 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 → `422`. | | published_at | string | Publish date — a unix timestamp or ISO-8601 date. Sending `null` is rejected (published_at can't be cleared). | | ttl | string | New TTL (e.g., `7d`) or `null` to make permanent. | | metadata | object | Nested string→string map of your attribution fields, merged with the existing map. A `null` value removes a key. The only channel for custom fields — an unknown top-level field → `422`. | | labels | object | Nested string→string map of your selector labels, merged with the existing map. A `null` value removes a key. Filterable on the list via `?label[key]=value`. | | if_labels | object | Optional atomic precondition: every supplied label must still equal its non-empty string value when the PATCH commits. A mismatch applies nothing and returns `409 state_conflict`. | Response (200): the updated Image object. Only ready images are mutable: moderation-locked images return `403 media_locked`, failed images return `422 media_failed`, and other non-ready images return `404 not_found`. On `409 state_conflict`, reload the image before deciding whether to retry. --- ## DELETE /v1/images/:id — Delete Image Authentication required. Permanently deletes the image and its CDN variants — not recoverable via the API. ```bash curl -X DELETE https://api.img.pro/v1/images/abc12345 \ -H "Authorization: Bearer img_live_…" ``` Response (200): `{ "id": "abc12345", "object": "image", "deleted": true }`. Deleting an already-deleted image returns 404. --- ## PATCH /v1/images/batch — Batch Update Authentication required. Update `caption`, `public`, `published_at`, `ttl`, `metadata`, or `labels` on up to 100 images at once (more than 100 ids → 422). The single-item PATCH's field restrictions and ready-only lifecycle gate apply; `metadata` / `labels` merge into each item. Lifecycle and optional `if_labels` failures appear per item in the 207 response while eligible items still update. ```json PATCH https://api.img.pro/v1/images/batch Authorization: Bearer img_live_… Content-Type: application/json { "ids": ["abc12345", "def45678"], "ttl": "7d", "public": false } ``` Response (200 all-ok / 207 partial) — one `batch_result` envelope. `data` holds a full Image object per updated item; `errors` holds one entry per skipped item, each with a nested error object: ```json { "object": "batch_result", "data": [ { "id": "abc12345", "object": "image", "...": "rest of the Image object" } ], "errors": [ { "id": "def45678", "error": { "type": "permission_error", "code": "media_locked", "message": "Media cannot be modified" } } ] } ``` --- ## DELETE /v1/images/batch — Batch Delete Authentication required. Delete up to 100 items by id. Same `batch_result` envelope; `data` holds a tombstone per id (idempotent — every id reports `deleted: true`, whether removed now or already gone): ```json DELETE https://api.img.pro/v1/images/batch Authorization: Bearer img_live_… Content-Type: application/json { "ids": ["abc12345", "def45678"] } ``` ```json { "object": "batch_result", "data": [ { "id": "abc12345", "object": "image", "deleted": true }, { "id": "def45678", "object": "image", "deleted": true } ], "errors": [] } ``` --- ## GET /v1/usage — Check Quota Authentication required. ```bash curl https://api.img.pro/v1/usage \ -H "Authorization: Bearer img_live_…" ``` ```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" } ``` --- ## App API — Build an App for Your Users Everything above uses ONE API key on YOUR own account. The App API is the other path: build an app where your users sign in to img.pro and you act on THEIR images and billing. There is no OAuth and no per-user token — your backend holds one machine secret (`img_sk_live_…`) and names the target with a header. Four steps: 1. **Sign the user in (hosted login).** Redirect their browser to: `https://img.pro/connect?app=&redirect_uri=&state=` (`redirect_uri` must exact-match a registered URI; `state` ≤512 chars, echoed back). They authenticate on img.pro and confirm consent, then img.pro redirects to `?code=&state=`. Verify `state`. On cancel you get `?error=access_denied&state=` (no code). 2. **Exchange the code (backend).** Single-use, ~60s: ``` POST https://api.img.pro/v1/auth/exchange Authorization: Bearer img_sk_live_… {"code": ""} → 200 {"object":"auth_context", "user":{"id":"…","email":"…","verified":true}, "app":{…}} ``` Persist the non-secret `user.id`. (`422 invalid_code` if bad/expired/used.) 3. **Act on their images.** Reuse the whole `/v1/images*` + `/v1/usage` surface above, authenticated by the machine secret + a user header: ``` POST https://api.img.pro/v1/images Authorization: Bearer img_sk_live_… X-Img-User: …multipart file=@… or JSON {"url":"…"}… ``` A user that isn't yours → `403 user_forbidden` (never 404). A data call with no `X-Img-User` → `422 validation_error`. 4. **Billing.** `GET /v1/billing/status` (+ X-Img-User) returns the user's `plan`, `usage`, `available_plans`, and a `billing_url` (= img.pro/apps/billing?app=) — redirect the user there to subscribe/upgrade/downgrade/cancel/portal (img.pro owns checkout + the return; one redirect, no Stripe URLs). You MUST append `redirect_uri` (REQUIRED; must exactly match a registered redirect URI, else error). You MAY append `plan`(pro|scale|max)+`interval`(monthly|annual) to PRE-SELECT the picker (user still confirms) and `state` (≤512, echoed back). Prereq: the user must have connected the app first. Learn the result by re-polling `/v1/billing/status` (completed subscribe/upgrade returns `?billing=success`). The API does no billing writes. App-specific error codes: `invalid_code` (422), `user_forbidden` / `app_suspended` (403), `app_context_unavailable` (503; honor `Retry-After`). Register your app at https://img.pro/apps (self-serve for paid img.pro accounts). Full guide: https://img.pro/api/app. --- ## Transformations Transforms are query parameters on the `url` field — the direct CDN URL returned on every response (always present, every plan). No auth, no extra API call; each distinct URL is cached at the edge. Append `?size=m` for a named preset, `?format=webp` to convert, `?w=800` to resize, `?segment=foreground` to remove the background, etc. | Param | Values | Description | |-------|--------|-------------| | size | s, m, l, social | Named preset that supplies the base recipe; it can be combined with the parameters below, and explicit query parameters override matching preset fields. s/m/l constrain the short side to 320/640/1080px (aspect preserved); social is a fixed 1200×630 OpenGraph card. | | format | jpg, png, webp, avif, gif | Output format. Overrides the path extension. webp = best size/quality; png = lossless + transparency. No `auto` (client-dependent, unsafe with shared caching). | | w, h | 1–4096 (px) | Target width / height. Set one to scale proportionally, or both with `fit`. | | fit | scale-down (default), contain, cover, crop, pad, squeeze | How the image fills w×h. cover/crop fill and trim; contain/pad letterbox; scale-down never enlarges. | | gravity | auto (default), face, left, right, top, bottom | Which part to keep when cropping. face centers on detected faces. | | zoom | 0–1 | Crop tightness around gravity=face (0 = most context, 1 = tight). | | q | 1–100, or high / medium-high / medium-low / low | Quality for lossy formats (JPEG/WebP/AVIF). Named levels ≈ 90/75/60/45. No effect on PNG (always lossless). | | brightness | 0–10 (default 1) | Multiplier. 0 = black, 2 = twice as bright. Useful ~0.5–2. | | contrast | 0–10 (default 1) | Multiplier. >1 = punchier. Useful ~0.7–2. | | gamma | 0–10 (default 1) | Multiplier. <1 lightens midtones, >1 darkens (0 and 1 are no-ops). Useful ~0.5–2.5. | | saturation | 0–10 (default 1) | Multiplier. 0 = grayscale, >1 = vivid. Useful ~0.5–2. | | blur | 0–250 (default 0) | Gaussian blur radius in pixels. | | sharp | 0–10 (default 0) | Unsharp-mask intensity. | | rotate | 90, 180, 270 | Clockwise rotation in degrees. | | flip | h, v, hv | Mirror horizontally, vertically, or both. | | trim | border | Auto-crop a uniform border color around the image. | | metadata | copyright (default), none, keep | EXIF retention. copyright strips GPS/device but keeps copyright tags; none strips all; keep retains all (incl. GPS). | | segment | foreground | Remove the background (edge segmentation). Add format=png to keep transparency. | | background | CSS named color, #hex (URL-encode # as %23), rgb(...), rgba(...) | Fill color for transparent areas. Pair with segment=foreground to swap the background; alone it fills transparent pixels. When omitted, alpha-capable formats stay transparent and others fill white. | | fx | blur-bg, color-pop, darken-bg | One-param composite (subject stays sharp, effect on the background). blur-bg = portrait blur; color-pop = grayscale background; darken-bg = spotlight. | | strength | per recipe | Tunes fx (only with fx). blur-bg: radius 0–250 (default 60). darken-bg: brightness 0.05–1 (default 0.5). color-pop: none. Out-of-range clamps. | | tile | An image id (same workspace) | Watermark — tile another of your images across the base. Cross-workspace / unknown ids are ignored. Design it as a seamless transparent PNG. | Color/tone multipliers (brightness, contrast, saturation, gamma) are capped at 0–10; values above produce degenerate output, so stay within the useful range. After signing in, tool pages at https://img.pro/tools are the no-URL alternative — drag-and-drop pages that produce a downloadable file. See https://img.pro/api/transforms for the full reference with examples. --- ## Plans & Pricing | Plan | Uploads/mo | Storage | Retention | Max file | Price | |------|-----------|---------|-----------|----------|-------| | Free | 1,000/mo | 10 GB | Permanent | 20 MB (20,000,000 bytes) | Free | | Pro | 10,000/mo | 100 GB | Permanent | 20 MB (20,000,000 bytes) | $29/mo | | Scale | 50,000/mo | 500 GB | Permanent | 20 MB (20,000,000 bytes) | $99/mo | | Max | 200,000/mo | 2 TB | Permanent | 20 MB (20,000,000 bytes) | $199/mo | Quotas reset on the first of each month. Upgrade links are signed one-click URLs (no login required) — included in quota error responses. --- ## Error Format Every error is one nested `error` object. Branch on `error.type` (coarse category) or switch on `error.code` (specific): ```json { "error": { "type": "quota_error", "code": "quota_exceeded", "message": "Human-readable description", "action": { "type": "upgrade | wait", "url": "https://...", "label": "Button text", "retry_after": 3600 } } } ``` `error.type` is one of: `invalid_request_error`, `authentication_error`, `permission_error`, `rate_limit_error`, `quota_error`, `idempotency_error`, `processing_error`, `api_error`. `action`/`usage`/`details` live inside `error`. Success responses never carry `error` or `action`. | Action type | When | What to do | |------------|------|------------| | upgrade | Quota reached | Show `error.action.url` — signed upgrade link, or contact support for top-tier accounts | | wait | Rate limited, App API context temporarily unavailable, identical create in flight, or create outcome still being reconciled | Wait `error.action.retry_after` seconds, then retry the same operation. Also check the `Retry-After` header | Common error codes (each maps to a `type`): - `validation_error` (422) — Invalid input, check the `details` object for field-level messages - `unauthorized` (401) — Missing or invalid API key - `forbidden` (403) — Key doesn't have the required ability (read/write) - `not_found` (404) — Resource doesn't exist or isn't accessible - `workspace_unavailable` (409) — Destination workspace is being deleted; restore or select an active workspace before retrying - `state_conflict` (409) — A PATCH `if_labels` precondition no longer matches; reload before deciding whether to retry - `idempotency_key_conflict` (409) — `Idempotency-Key` reused with a different request body - `idempotency_key_in_progress` (409) — same `Idempotency-Key` retried while the original is still in flight; back off per `Retry-After` - `idempotency_recovery_conflict` (409) — canonical create row no longer matches a safely recoverable operation; stop automatic retries - `quota_exceeded` (403) — Upload or storage quota reached - `rate_limited` (429) — Too many requests - `upload_failed` (500) — Internal processing failure on a valid upload (retry later); too-large/invalid-format are validation_error (422), not this - `fetch_failed` (422/502/504) — URL import redirected to a disallowed address, failed upstream, or timed out - `create_outcome_ambiguous` (503) — canonical create write is still being reconciled; retry the identical request after `Retry-After` - `app_context_unavailable` (503) — App API authority or quota context could not complete within its budget; retry after `Retry-After` App API also adds: `invalid_code` (422), `user_forbidden` (403), `app_suspended` (403), and `app_context_unavailable` (503). --- ## Response Headers Authenticated upload/import responses include quota headers: ``` X-Monthly-Uploads-Used: 42 X-Monthly-Uploads-Limit: 1000 X-Monthly-Uploads-Remaining: 958 X-Storage-Used: 52428800 X-Storage-Limit: 10737418240 X-Storage-Remaining: 10684989440 ``` Other headers: `Retry-After` (429 + app_context_unavailable + idempotency_key_in_progress + create_outcome_ambiguous), `Idempotent-Replayed` (true on a replayed create). --- ## Links - Interactive docs: https://img.pro/api - Quick start: https://img.pro/api/quick-start - Authentication: https://img.pro/api/authentication - API reference: https://img.pro/api/reference - The Image object: https://img.pro/api/image-object - Transformations: https://img.pro/api/transforms - Error reference: https://img.pro/api/errors - Building an app: https://img.pro/api/app - AI agents guide: https://img.pro/api/ai-agents - OpenAPI spec: https://img.pro/openapi.yaml - Pricing: https://img.pro/pricing