Error envelope
Every error response has this shape. Branch on code; message is for humans.
| Field | Type | Required | Description |
|---|---|---|---|
error.code | string | yes | |
error.message | string | yes | |
error.details | any | no | Route-specific; zod issues for invalid_request |
85 operations in 20 groups, generated from the API's OpenAPI contract. The SDKs in the quickstart wrap the customer-facing operations; this page lists all of them.
Control plane and upload api of img2site. Errors always use the envelope { error: { code, message, details? } }. Every response carries x-request-id (a sane caller-supplied X-Request-Id is echoed). Operations marked x-internal are infrastructure endpoints that SDKs do not wrap.
Every path carries the version prefix /v1: configure clients with https://api.img2.site and call, for example, https://api.img2.site/v1/projects/{slug}/uploads. The proxy upload PUT goes to the proxy.put_url a session returns; its host is deployment-defined (today the same https://api.img2.site), so use the URL as returned instead of building it.
https://api.img2.site — Every operation. The proxy upload PUT is served on PUBLIC_UPLOAD_ORIGIN, which is deployment-defined and today this same origin; always use proxy.put_url as returned.Each operation lists the credential sets it accepts, joined by "or"; a set joined by "+" needs every part. Scopes in parentheses are the API key scopes required (a key without scopes has all of them). Server code uses ApiKey or ApiKeyHeader; the browser-session schemes are for the img2site apps.
| Scheme | Transport | Description |
|---|---|---|
ApiKey | HTTP bearer (i2s_live_ + 36 hex) | Authorization: Bearer i2s_live_…. Project-bound; the requirement lists the scope needed (a key without scopes has all). A key of a suspended project does not authenticate. |
ApiKeyHeader | header X-Api-Key | Same key as ApiKey, in X-Api-Key (checked first when both are sent). |
BrowserSession | HTTP bearer (JWT) | WorkOS AuthKit access token (RS256, client-scoped issuer, exp and sub required). A JWKS outage is 503 auth_unavailable. An API key on a browser-only route is 403 browser_session_required. |
WorkosUserId | header x-workos-user-id | Must equal the token's sub (else 401). Optional display headers x-workos-user-email, x-workos-user-first-name, x-workos-user-last-name and x-workos-user-name are stored on first login and never used for authorization. |
RequestSignature | header X-Request-Signature | Browser writes (POST/PUT/PATCH/DELETE) only: hex hmac-sha256(WEB_ADMIN_API_SIGNING_SECRET, METHOD + LF + pathname + LF + raw body). The query string is not covered; an empty body signs as the empty string. Missing or wrong is 401 invalid_request_signature. |
Staff | HTTP bearer (JWT) | A BrowserSession whose sub is in STAFF_WORKOS_USER_IDS while STAFF_API_ENABLED=true. With the staff api disabled /v1/staff/* is 404; a non-staff session is 403 staff_only. Writes carry RequestSignature like every browser write. |
StripeSignature | header Stripe-Signature | t=…,v1=… over the raw body (300 s tolerance). |
CreemSignature | header creem-signature | Lowercase hex hmac-sha256(CREEM_WEBHOOK_SECRET, raw body); no timestamp. |
StorageEventSignature | header X-Bz-Event-Notification-Signature | v1=hex hmac-sha256(STORAGE_EVENTS_SECRET, raw body). |
UploadUrlSignature | query sig | Signed proxy URL (exp and sig query parameters) from proxy.put_url. |
LibrarySession | HTTP bearer (ls1.{kid}.{payload}.{mac}) | A library session token from POST …/library-sessions. Accepted ONLY on /v1/library/*; every other route answers 401 library_session_not_allowed. Same-origin with the /embed page (no CORS). |
EmbedUrlSignature | query s | Signed embed URL (o, exp, kid, s) from embed_url: s = base64url hmac-sha256(EMBED_URL_SECRET[kid], embed1|project|library|o|exp). |
Every error response has this shape. Branch on code; message is for humans.
| Field | Type | Required | Description |
|---|---|---|---|
error.code | string | yes | |
error.message | string | yes | |
error.details | any | no | Route-specific; zod issues for invalid_request |
Example
{
"error": {
"code": "file_too_large",
"message": "…",
"details": { "max_bytes": 100000000 }
}
}| Response | Codes |
|---|---|
| BadRequest | invalid_request, invalid_json, invalid_cursor and route-specific 400 codes (uploads: invalid_parts, not_multipart, parts_required, invalid_part_size, multipart_complete_failed; media library: invalid_library_slug, invalid_folder, invalid_search, invalid_bbox, folder_requires_library, filter_requires_library, not_supported, default_library_immutable) |
| unauthorized, invalid_request_signature, invalid_signature, signature_expired | |
| Forbidden | browser_session_required, api_key_required, forbidden (scope), staff_only, staff_only_field, project_suspended, project_suspended_by_staff, storage_quota_exceeded, raw_download_disabled |
| NotFound | not_found (also every cross-tenant miss), file_not_found, upload_not_found, library_not_found, content_missing (staffGetCheckImage: the check exists, its scanned content object is no longer stored) |
| Conflict | project_exists, api_key_quota_exceeded, api_key_revoked, recipe_name_taken, watermark_preset_name_taken, default_watermark_in_use, watermark_file_not_ready, file_not_ready, signing_key_missing, ingest_object_missing, ingest_size_mismatch, upload_not_pending, upload_not_resumable, already_subscribed, use_billing_portal, no_subscription, already_cancelling, custom_domain_taken, check_not_flagged, incident_automatic, media_library_disabled, library_slug_taken, library_not_empty |
| Gone | upload_expired (details.expires_at); start a new upload. refreshUploadParts: the session is expired (by the reconciler or an abort) or at or past its expires_at, or less than ~2 s are left to sign. completeUpload: a created multipart session at or past its expires_at sent with parts, or without parts when no object exists at exactly size_bytes. |
| LengthRequired | length_required |
| PayloadTooLarge | payload_too_large (details.max_bytes), file_too_large (createUpload: size_bytes above INGEST_MAX_BYTES, or above 2 000 000 for image/svg+xml; details.max_bytes), proxy_upload_too_large (createUpload with mode proxy above PROXY_MAX_BYTES; details.max_bytes, details.mode), preview_too_large (staffGetCheckImage; details.max_bytes) |
| UnsupportedMediaType | content_type_mismatch |
| Unprocessable | sha256_mismatch; filter_unavailable (details.max_files): media library filters, sorts, library create/delete and file folder/library changes on a project with more files rows (any status) than MEDIA_LIBRARY_MAX_UNINDEXED_FILES until the library indexes are built (phase 1b); search and status=deleted listings with library filters stay size-gated after that, with their own message; preview_unavailable (staffGetCheckImage; details.reason is the render error code) |
| TooManyRequests | rate_limited, with Retry-After (seconds) |
| Internal | internal (the cause is logged, never returned) |
| auth_unavailable, storage_unavailable, query_timeout (a database statement timed out), billing_unavailable, stripe_request_failed, creem_request_failed, billing_price_mismatch, billing_customer_unmapped, billing_plan_unmapped, billing_subscription_mismatch, billing_grant_not_found, preview_busy (with Retry-After and details.retry_after_seconds) and other retryable 503s |
Liveness and readiness of the api process. Infrastructure only.
GET /healthzLiveness (the process answers) Internal: infrastructure endpoint, not wrapped by the SDKs.
Operation: getHealthz · Auth: none
Responses: 200 Alive (Healthz)
GET /readyzReadiness (db, ingest bucket, content bucket) Internal: infrastructure endpoint, not wrapped by the SDKs.
Operation: getReadyz · Auth: none
Responses: 200 Ready (Readyz), 503 A dependency failed; the body has the same shape with ready false. (Readyz)
Called by the storage provider, not by customers.
POST /v1/internal/storage-eventsB2 event notifications (ObjectCreated completes the matching upload). Body limit 1 MiB. Internal: infrastructure endpoint, not wrapped by the SDKs.
Operation: postStorageEvents · Auth: StorageEventSignature
Body (application/json, StorageEvents, required)
| Field | Type | Required | Description |
|---|---|---|---|
events | object[] | yes |
Responses: 200 Counted (StorageEventsResult), 400 BadRequest, 401 Unauthorized, 413 PayloadTooLarge, 500 Internal
The signed-in dashboard user and their organization.
GET /v1/meThe session's user, organization, role and staff flag
Operation: getMe · Auth: BrowserSession + WorkosUserId
Responses: 200 The caller (Me), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/organizationsOnly the caller's organization (next_cursor is always null)
Operation: listOrganizations · Auth: BrowserSession + WorkosUserId
Responses: 200 One page (OrganizationList), 401 Unauthorized, 403 Forbidden, 429 TooManyRequests, 503 Unavailable
POST /v1/organizationsStaff create an organization (201); a tenant gets its own provisioned organization back (200, body ignored)
Operation: createOrganization · Auth: BrowserSession + WorkosUserId + RequestSignature
Body (application/json, OrganizationCreate, optional)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes |
Responses: 200 Tenant caller: its own organization (Organization), 201 Staff caller: the new organization (Organization), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Projects own files, keys, presets and a delivery host.
GET /v1/projectsProjects of the caller's organization, keyset, newest first
Operation: listProjects · Auth: BrowserSession + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (ProjectList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 429 TooManyRequests, 503 Unavailable
POST /v1/projectsCreate a project and its first signing key (body limit 256 KiB)
Operation: createProject · Auth: BrowserSession + WorkosUserId + RequestSignature
Body (application/json, ProjectCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
project_slug | string | no | Required (or its deprecated alias slug); DNS label, not reserved |
slug | string | no | |
organization_id | string | null | no | Staff only: create in another existing organization |
name | string | no | |
delivery_access | public | protected | no | |
cors_allowed_origins | string[] | no | |
resolution_presets | object | no | |
allowed_transform_chains | string[] | no |
Responses: 201 Created (Project), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}Operation: getProject · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The project (Project), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PATCH /v1/projects/{slug}Owner settings; storage_hard_limit_bytes and pricing_multiplier are staff-only (403 staff_only_field); custom_domains is refused (400, details.hint staff route). Body limit 256 KiB.
Operation: updateProject · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, ProjectPatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
delivery_access | public | protected | no | |
cors_allowed_origins | string[] | no | |
resolution_presets | object | no | |
allowed_transform_chains | string[] | no | |
status | active | suspended | no | |
default_watermark_preset_id | string | null | no | |
project_default_watermark | string | null | no | Deprecated alias; an empty string clears |
raw_download_enabled | boolean | no | |
callback_url | string | null | no | |
rotate_callback_secret | boolean | no | |
storage_hard_limit_bytes | integer | null | no | Staff only |
pricing_multiplier | number | no | Staff only |
Responses: 200 The updated project; callback_secret is present once when a secret was generated or rotated (Project), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}Suspends the project (suspended_by owner). There is no hard delete.
Operation: suspendProject · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 204 Suspended (repeat calls too), 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Project API keys (created in the dashboard) and key introspection for SDKs.
GET /v1/keyThe calling API key (SDKs resolve their project from it); Cache-Control no-store
Operation: getKey · Auth: ApiKey or ApiKeyHeader
Responses: 200 The key (KeyIntrospection), 401 Unauthorized, 403 Forbidden, 429 TooManyRequests
GET /v1/projects/{slug}/keysEvery key of the project, newest first (bounded, next_cursor null)
Operation: listApiKeys · Auth: BrowserSession + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The keys (ApiKeyList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/keysNew key; plaintext_secret is returned only here. Omitted scopes = full project access.
Operation: createApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, ApiKeyCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
scopes | files:read | files:write | urls:sign | recipes:read | recipes:write | library:sessions[] | no |
Responses: 201 Created (ApiKeyCreated), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/keys/{keyId}/revokeOperation: revokeApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
keyId | path | yes | key_… id |
Responses: 200 The key, now revoked (idempotent) (ApiKeyEnvelope), 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/keys/{keyId}/rotateNew key with the same name and scopes; the old one is revoked in the same transaction
Operation: rotateApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
keyId | path | yes | key_… id |
Responses: 200 The new key (ApiKeyRotated), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Delivery signing keys and signed delivery URLs.
GET /v1/projects/{slug}/signing-keyskid, status and retire_after of every signing key (never secrets)
Operation: listSigningKeys · Auth: BrowserSession + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The signing keys (SigningKeyList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/signing-keys/rotateNew active kid; the previous one keeps verifying for 24 h
Operation: rotateSigningKey · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 Rotated (SigningKeyRotation), 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/signed-urlSigned delivery URL for a ready file (own rate limit, 60/min/project; audited)
Operation: createSignedUrl · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (urls:sign) or ApiKeyHeader (urls:sign)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, SignedUrlRequest, required)
| Field | Type | Required | Description |
|---|---|---|---|
file_slug | string | yes | |
transform_chain | string | null | no | A delivery chain; the deprecated values originals and download/raw imply the kind |
kind | derived | originals | raw | no | Default: derived with a chain, else originals |
expires_in_seconds | integer | null | no | 60..3600, default 300 |
Responses: 200 The URL (SignedUrl), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Watermark presets; each image is a ready file of the same project.
GET /v1/projects/{slug}/watermark-presetsEvery preset of the project (bounded; no next_cursor)
Operation: listWatermarkPresets · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The presets (WatermarkPresetList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PUT /v1/projects/{slug}/watermark-presetsCreates a new preset on every call (not idempotent). The image is one of the project's ready files.
Operation: createWatermarkPreset · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, WatermarkPresetInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
file_slug | string | yes | One of the project's ready files |
x_offset | number | no | |
y_offset | number | no | |
opacity | number | no | 0..100, default 100 |
Responses: 201 Created (WatermarkPreset), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/watermark-presets/{id}Operation: getWatermarkPreset · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | wm_… id |
Responses: 200 The preset (WatermarkPreset), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PUT /v1/projects/{slug}/watermark-presets/{id}Creates (201) or replaces (200) the preset with this id
Operation: saveWatermarkPreset · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | wm_… id |
Body (application/json, WatermarkPresetInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
file_slug | string | yes | One of the project's ready files |
x_offset | number | no | |
y_offset | number | no | |
opacity | number | no | 0..100, default 100 |
Responses: 200 Replaced (WatermarkPreset), 201 Created (WatermarkPreset), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}/watermark-presets/{id}An unknown id is 404; the project default is 409 default_watermark_in_use
Operation: deleteWatermarkPreset · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | wm_… id |
Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Named transform chains.
GET /v1/projects/{slug}/recipesEvery recipe of the project (bounded, next_cursor null)
Operation: listRecipes · Auth: BrowserSession + WorkosUserId or ApiKey (recipes:read) or ApiKeyHeader (recipes:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The recipes (RecipeList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PUT /v1/projects/{slug}/recipesCreates a new recipe on every call (not idempotent)
Operation: createRecipe · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (recipes:write) or ApiKeyHeader (recipes:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, RecipeInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
transform_chain | string | yes | |
schema_version | 1 | no |
Responses: 201 Created (Recipe), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/recipes/{id}Operation: getRecipe · Auth: BrowserSession + WorkosUserId or ApiKey (recipes:read) or ApiKeyHeader (recipes:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | rcp_… id |
Responses: 200 The recipe (Recipe), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PUT /v1/projects/{slug}/recipes/{id}Creates (201) or replaces (200) the recipe with this id
Operation: saveRecipe · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (recipes:write) or ApiKeyHeader (recipes:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | rcp_… id |
Body (application/json, RecipeInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
transform_chain | string | yes | |
schema_version | 1 | no |
Responses: 200 Replaced (Recipe), 201 Created (Recipe), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}/recipes/{id}Idempotent: an unknown id is 204 too
Operation: deleteRecipe · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (recipes:write) or ApiKeyHeader (recipes:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | rcp_… id |
Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Published files of a project.
GET /v1/projects/{slug}/filesKeyset, newest first; no status filter = every file that is not deleted. library, folder, search, has_location, bbox or a sort other than uploaded on a project with more files rows (any status) than MEDIA_LIBRARY_MAX_UNINDEXED_FILES (default 100000; 0 = no limit) is 422 filter_unavailable until the library indexes are built (phase 1b; the gate lifts on its own once they are, within 5 min); search ('search is not available on projects this large') and status=deleted with any of those ('listing deleted files with library filters is not available on projects this large') stay size-gated after that (no index serves them). Checked after query validation and library resolution (malformed input is still 400, an unknown library 404); status, q and the plain listing always work. q must not contain U+0000 (400 invalid_request).
Operation: listFiles · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
status | query | no | |
q | query | no | Slug prefix |
library | query | no | Library slug or default; narrows to that library only. Unknown or deleted is 404 library_not_found. Without it the list is project-wide, as before the media library. |
folder | query | no | Folder path (normalized; empty = the library root); requires library (400 folder_requires_library); malformed is 400 invalid_folder |
recursive | query | no | With folder: include the whole subtree. Default false. |
search | query | no | At least 2 characters after trim (400 invalid_search); case-insensitive literal substring of title, original_filename and slug; requires library (400 filter_requires_library) |
has_location | query | no | Files with (true) or without (false) coordinates; requires library |
bbox | query | no | west,south,east,north in degrees; west > east crosses the antimeridian; requires library; malformed or out of range is 400 invalid_bbox |
sort | query | no | uploaded (default, newest first), name (ascending), taken (newest first, undated last); name and taken require library. A cursor is only valid with the sort it was issued for (400 invalid_cursor). |
uploader | query | no | A subject (as given to uploads or library sessions): only files whose current revision it uploaded (files.uploader_key). Works with or without library; needs SUBJECT_KEY_SECRET (409 subject_key_unavailable); malformed is 400 invalid_subject. On a project over MEDIA_LIBRARY_MAX_UNINDEXED_FILES it is 422 filter_unavailable until both uploader indexes are built (worker build-indexes), and always with status=deleted. The response carries filters { uploader: true }. |
include | query | no | Comma list of extra fields; only location is known (anything else is 400 invalid_request) |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page (up to 4096 characters: a name-sort cursor carries the sort key); a tampered cursor or one from another sort is 400 invalid_cursor |
Responses: 200 One page (FileList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/files/{fileSlug}A deleted file is 404 file_not_found
Operation: getFile · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
fileSlug | path | yes | |
include | query | no | Comma list of extra fields; only location is known (anything else is 400 invalid_request) |
Responses: 200 The file (File), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PATCH /v1/projects/{slug}/files/{fileSlug}Title, alt text, folder, library. folder/library need MEDIA_LIBRARY_WRITES (409 media_library_disabled); title/alt_text always work. A folder or library change is also 422 filter_unavailable on a project over MEDIA_LIBRARY_MAX_UNINDEXED_FILES files rows (any status) until the library indexes are built (phase 1b), checked after body validation and library resolution. A library change without folder puts the file at the target's root; it purges nothing in phase 1 (every library is inherit). A deleted file is 404 file_not_found. Audit file.update when something changed.
Operation: updateFile · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
fileSlug | path | yes |
Body (application/json, FilePatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
title | string | null | no | Control and bidi characters stripped, trimmed, cut to 200 characters; empty or null clears |
alt_text | string | null | no | As title, cut to 1000 characters |
folder | string | no | Needs MEDIA_LIBRARY_WRITES; normalized (400 invalid_folder) |
library | string | no | Library slug or default; needs MEDIA_LIBRARY_WRITES; unknown or deleted is 404 library_not_found |
Responses: 200 The file (without location) (File), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}/files/{fileSlug}status deleted + purge; repeat deletes are 204; the object is left for gc
Operation: deleteFile · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
fileSlug | path | yes |
Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/files/{fileSlug}/moderation-notesOne note per (project, file slug); a second replaces it
Operation: createModerationNote · Auth: BrowserSession + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
fileSlug | path | yes |
Body (application/json, ModerationNoteInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
note | string | no | |
body | string | no |
Responses: 201 The note (ModerationNote), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/files/moveBulk move (1..1000 slugs, one transaction) into one folder, and into one library when given (omitted = each file keeps its library). Needs MEDIA_LIBRARY_WRITES. On a project with more files rows (any status) than MEDIA_LIBRARY_MAX_UNINDEXED_FILES (default 100000; 0 = no limit) this is 422 filter_unavailable until the library indexes are built (phase 1b), checked after body validation and library resolution. Slugs with no live file (or that are not valid slugs) are listed in not_found. No purge and no webhook in phase 1 (slugs and URLs do not change; the response lists the moved files; control-plane.md). Audit file.move with counts. Body up to 256 KiB.
Operation: moveFiles · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, FileMove, required)
| Field | Type | Required | Description |
|---|---|---|---|
file_slugs | string[] | yes | 1..1000; duplicates ignored |
folder | string | yes | |
library | string | no | Library slug or default; omitted = each file keeps its library |
Responses: 200 Where every requested slug ended up (FileMoveResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/librariesLive named libraries, keyset, newest first; the built-in default library is not listed
Operation: listLibraries · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (LibraryList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/librariesNeeds MEDIA_LIBRARY_WRITES (409 media_library_disabled). slug is trimmed and lowercased (400 invalid_library_slug; default is reserved); a live library already using it is 409 library_slug_taken (a deleted library's slug is reusable). On a project with more files rows (any status) than MEDIA_LIBRARY_MAX_UNINDEXED_FILES (default 100000; 0 = no limit) this is 422 filter_unavailable until the library indexes are built (phase 1b), checked after body validation. Audit library.create.
Operation: createLibrary · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, LibraryCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | yes | |
name | string | yes | 1..120 characters after trim |
allowed_origins | string[] | no | Up to 200 origins: https://host[:port], or exactly http://localhost:<port> / http://127.0.0.1:<port>; no path, no wildcard |
Responses: 201 The library (Library), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/libraries/{lib}default answers the synthetic default library (library_id null); an unknown or deleted slug is 404 library_not_found
Operation: getLibrary · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
lib | path | yes | Library slug, or default for the project's built-in library |
Responses: 200 The library (Library), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PATCH /v1/projects/{slug}/libraries/{lib}Needs MEDIA_LIBRARY_WRITES. delivery_access and map_enabled are 400 not_supported in phase 1. default accepts only allowed_origins, stored as the project-wide library origins (they admit an embedding origin on every library of the project); anything else on default is 400 default_library_immutable. Audit library.update when a field was given.
Operation: updateLibrary · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
lib | path | yes | Library slug, or default for the project's built-in library |
Body (application/json, LibraryPatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
allowed_origins | string[] | no | |
delivery_access | any | no | Phase 2. Known but refused in phase 1: any value is 400 not_supported (details.fields). |
map_enabled | any | no | Phase 3. Known but refused in phase 1: any value is 400 not_supported (details.fields). |
Responses: 200 The library (Library), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}/libraries/{lib}Needs MEDIA_LIBRARY_WRITES. Soft delete; 409 library_not_empty while it holds ready or blocked files or created/uploaded/processing uploads; default is 400 default_library_immutable. On a project with more files rows (any status) than MEDIA_LIBRARY_MAX_UNINDEXED_FILES (default 100000; 0 = no limit) this is 422 filter_unavailable until the library indexes are built (phase 1b; before that the emptiness check scans the project), checked after the library resolved (400/404 first). Audit library.delete.
Operation: deleteLibrary · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
lib | path | yes | Library slug, or default for the project's built-in library |
Responses: 204 Deleted, 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/library-sessionsMint a short-lived ls1 library session for one end user (subject) and one embedding origin, plus the signed embed_url of the picker. API keys only (scope library:sessions; browse/browse_own also need files:read, upload files:write, and a protected scope urls:sign: else 403 scope_missing). 409 library_sessions_disabled while LIBRARY_SESSIONS is off; 409 subject_key_unavailable without SUBJECT_KEY_SECRET. origin must be listed in the library's allowed_origins or the project-wide ones (PATCH …/libraries/default), else 403 origin_not_allowed. browse and browse_own together are 400 invalid_permissions. refresh_of: a live (not revoked) token of the same project, library, origin and subject (≤ 10 min past its exp) keeps its sid; one that differs or widens is 400 refresh_mismatch; an unverifiable or revoked one starts a new session. Within a revocation cutoff (now ≤ the project's or library's sessions_not_before) mint is 409 sessions_recently_revoked (Retry-After). A key with no scopes (legacy, all scopes) may mint. No SQL write per mint; a per-pod limiter answers 429.
Operation: createLibrarySession · Auth: ApiKey (library:sessions) or ApiKeyHeader (library:sessions)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, LibrarySessionCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
library | string | yes | Library slug, or default (required) |
folder | string | no | Scope: this folder and its subtree (normalized; 400 invalid_folder). Default '' = the whole library. |
permissions | browse | browse_own | upload[] | yes | browse = the whole scope, browse_own = only this subject's uploads (exclusive with browse), upload; upload alone is the upload-only picker |
origin | string | yes | The page that embeds the picker: an https origin, or an exact http://localhost:<port> / http://127.0.0.1:<port> registered as such |
subject | string | yes | The customer's pseudonymous id of its end user (1..128 characters) |
expires_in | integer | no | 60..3600 seconds, default 3600 |
max_file_bytes | integer | no | Per-file cap of session uploads; default min(25 MiB, the proxy limit), at most the proxy limit |
max_uploads | integer | no | Uploads one picker session may create (1..1000, default 100; per pod) |
refresh_of | string | no | The token being refreshed |
Responses: 201 The session (LibrarySessionCreated), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/library/filesThe session's visible set: ready files of the token's library and folder subtree, newest first; with browse_own only the files whose current revision this subject uploaded (size-gated with 422 filter_unavailable until the uploader indexes exist). 403 permission_denied without browse/browse_own. Thumbnails: unsigned for a public scope whose project allowlists the thumbnail chain, else signed with the project's active key (hourly, shared per project; no /signed-url budget). An ls1 token is the only credential; 401 session_expired, session_revoked, minting_key_revoked or invalid_session ends it.
Operation: listLibrarySessionFiles · Auth: LibrarySession
| Parameter | In | Required | Description |
|---|---|---|---|
search | query | no | At least 2 characters after trim (400 invalid_search); case-insensitive literal substring of title, original_filename and slug |
limit | query | no | 1..60, default 40 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (LibraryFileList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 409 Conflict, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
POST /v1/library/uploadsUpload into the session's library and folder (permission upload; MEDIA_LIBRARY_WRITES). The file slug is always generated (a session never overwrites a file); the subject comes from the token (files.uploader_key at publish). Proxy mode only: PUT the bytes to put_url (≤ min(15 min, token exp)). size_bytes over the session's max_file_bytes is 413 file_too_large; more than max_uploads uploads in one picker session is 429 session_upload_limit (per pod).
Operation: createLibrarySessionUpload · Auth: LibrarySession
Body (application/json, LibraryUploadCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
content_type | string | yes | |
size_bytes | integer | yes | |
original_filename | string | null | no | |
title | string | null | no |
Responses: 201 The upload and its PUT URL (LibraryUploadCreated), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/library/uploads/{id}An upload of THIS picker session (its sid, kept across refresh_of); anything else is 404 upload_not_found. file is set once the upload is ready and the file is still that upload's revision inside the session's scope.
Operation: getLibrarySessionUpload · Auth: LibrarySession
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | upl_… id |
Responses: 200 The upload (LibraryUpload), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 429 TooManyRequests, 503 Unavailable
POST /v1/library/uploads/{id}/completeIdempotent fallback (the proxy PUT already completes the upload): completes a created upload whose bytes are stored (409 ingest_object_missing otherwise). Own picker session only.
Operation: completeLibrarySessionUpload · Auth: LibrarySession
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | upl_… id |
Responses: 200 The upload (LibraryUpload), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /embed/{project}/{library}The picker page (open embed_url in an iframe; protocol img2site.library.v1 in contracts/v2/sdk-and-apps.md). Checked locally against EMBED_URL_SECRET, no database lookup. 200 carries Content-Security-Policy with frame-ancestors <o>, script-src/style-src 'self', no inline code, Referrer-Policy no-referrer and Cache-Control no-store. Refusals carry frame-ancestors 'none': 400 invalid_origin, 403 invalid_embed_url or embed_url_expired, 404 while LIBRARY_SESSIONS is off.
Operation: getLibraryEmbed · Auth: EmbedUrlSignature
| Parameter | In | Required | Description |
|---|---|---|---|
project | path | yes | |
library | path | yes | Library slug or default |
o | query | yes | The embedding origin, exactly as signed |
exp | query | yes | |
kid | query | yes | |
s | query | yes |
Responses: 200 The picker page, 400 BadRequest, 403 Forbidden, 404 NotFound
GET /embed/_assets/{file}The picker's script and stylesheet (fixed names, Cache-Control public, max-age=300). 404 for any other name or while LIBRARY_SESSIONS is off; 503 embed_unavailable when the image was built without them.
Operation: getLibraryEmbedAsset · Auth: none
| Parameter | In | Required | Description |
|---|---|---|---|
file | path | yes |
Responses: 200 The asset, 404 NotFound, 503 Unavailable
Upload sessions: create, send bytes (direct, multipart or proxy), complete, read.
GET /v1/projects/{slug}/uploadsUpload sessions, keyset, newest first
Operation: listUploads · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
status | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (UploadSessionList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/uploadsCreate an upload session (upload-flow.md). Exactly one of direct, multipart, proxy is present. A single-part session (direct PUT or proxy) lives 15 min; a multipart session (parts > 1) lives 12 h (expires_at) while its part URLs last 15 min (multipart.urls_expire_at), re-signed by POST …/parts. size_bytes above INGEST_MAX_BYTES is 413 file_too_large; with mode proxy, above PROXY_MAX_BYTES (default 100000000, Cloudflare's request body limit) it is 413 proxy_upload_too_large (use direct; multipart for files above 100 MB). library/folder need MEDIA_LIBRARY_WRITES (409 media_library_disabled); an unknown or deleted library is 404 library_not_found.
Operation: createUpload · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, UploadCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
file_slug | string | no | Default: the upload id lower-cased |
content_type | image/jpeg | image/png | image/webp | image/avif | image/gif | image/tiff | image/bmp | image/svg+xml | yes | image/svg+xml is accepted only when the deployment enables SVG uploads (api SVG_UPLOADS=true); otherwise 400 unsupported_content_type. An svg session is limited to 2 000 000 bytes (413 file_too_large). |
size_bytes | integer | yes | |
sha256 | string | no | |
mode | direct | proxy | no | |
parts | integer | no | 1..10000; more than 1 selects S3 multipart (direct mode only, parts of at least 5 MiB) |
library | string | no | Library slug or default; needs MEDIA_LIBRARY_WRITES. Omitted = a re-upload keeps the file's library (a new file: default). |
folder | string | no | Needs MEDIA_LIBRARY_WRITES; normalized (400 invalid_folder). Omitted = keep (a new file: the root). |
title | string | null | no | Always accepted; cleaned and cut to 200 characters |
original_filename | string | null | no | Always accepted; cleaned and cut to 255 UTF-8 bytes |
subject | string | no | The customer's pseudonymous id of the end user uploading (1..128 characters, no control or bidi characters; never an email or name; 400 invalid_subject). Stored as given and as s1:HMAC(SUBJECT_KEY_SECRET, project_id|subject), which publish copies to files.uploader_key (GET …/files?uploader=, browse_own sessions). Every publish sets uploader_key, so a re-upload without subject makes the file unowned. Without SUBJECT_KEY_SECRET: 409 subject_key_unavailable. |
Responses: 201 The session and where to put the bytes (UploadCreated), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/projects/{slug}/uploads/{id}Operation: getUpload · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | upl_… id |
Responses: 200 The session (UploadSession), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
DELETE /v1/projects/{slug}/uploads/{id}Abort a session the client gave up on (upload-flow.md, Aborting an upload). created becomes expired with error_code upload_aborted, then a multipart upload is aborted in storage (best effort; a refusal is logged and the bucket lifecycle is the backstop). Idempotent: an expired session (aborted or expired by the reconciler) answers 204 again and retries the multipart abort. Any later status is 409 upload_not_pending (details.status). On a suspended project it works for browser sessions only (API keys of a suspended project do not authenticate). Losing the race with a complete that already moved the session to uploaded is 409, and the file may still become ready. Same auth as complete, exempt from the project write limit. Frees the session at once for library deletion, which a pending session blocks until it expires (12 h for multipart).
Operation: abortUpload · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | upl_… id |
Responses: 204 Aborted (or already expired), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/uploads/{id}/completeOptional accelerator; completes an S3 multipart first. Body optional except for multipart (limit 1 MiB); a multipart session without parts completes only when its object is already assembled at exactly size_bytes (an api crash after CompleteMultipartUpload), else 400 parts_required: parts must be exactly 1..N with non-blank etags, all uploaded (read-only part listing), every part but the last at least 5 MiB, adding up to size_bytes, else 400 invalid_parts and the multipart upload stays intact and completable. A part listing the backend refuses is 503 storage_unavailable. A multipart session at or past its expires_at is 410 upload_expired (nothing is assembled; start a new upload), except without parts when its object was already assembled at exactly size_bytes, which still completes.
Operation: completeUpload · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | upl_… id |
Body (application/json, UploadComplete, optional)
| Field | Type | Required | Description |
|---|---|---|---|
parts | object[] | no | Required for multipart sessions: every part of the session, part_number 1..N once each (any order); a partial list is 400 invalid_parts |
Responses: 200 Already past created: the session unchanged (UploadSession), 202 Transitioned to uploaded; processing is queued (UploadSession), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 410 Gone, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/projects/{slug}/uploads/{id}/partsResume a multipart session (upload-flow.md, Resuming a multipart upload): the parts storage already holds (part_number, size, etag) and freshly signed URLs for part_numbers, by default every part not uploaded yet (lowest first, at most 1000). Read-only: expires_at does not move; each URL lasts min(15 min, until expires_at) and signs its part's exact Content-Length. Same auth and body limit (64 KiB) as create, exempt from the project write limit like create and complete. Checked in this order: 404 upload_not_found, 403 project_suspended, the body (400 invalid_json, then 400 invalid_request for the schema: unknown keys, not an array, 0 or more than 1000 entries, a number outside 1..10000), 400 not_multipart, 410 upload_expired / 409 upload_not_pending by status, 409 upload_not_resumable, 410 upload_expired by expires_at, then 400 invalid_parts (above part_count, then repeats), then the listing (503), and 410 again when less than ~2 s are left to sign. 400 not_multipart for a single-part session, 400 invalid_parts for part numbers above part_count or repeated; 409 upload_not_pending past created (or when the multipart upload is no longer open), 409 upload_not_resumable for a multipart session created before part refresh existed; 410 upload_expired at or after expires_at; 503 storage_unavailable when storage refuses the part listing.
Operation: refreshUploadParts · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (files:write) or ApiKeyHeader (files:write)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
id | path | yes | upl_… id |
Body (application/json, UploadPartsRequest, optional)
| Field | Type | Required | Description |
|---|---|---|---|
part_numbers | integer[] | no | 1..1000 entries, each in 1..part_count, no repeats (400 invalid_parts; above part_count is checked against the session). Omitted (or no body) = every part not uploaded yet, lowest first, at most 1000. |
Responses: 200 Uploaded parts and fresh part URLs (UploadParts), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 410 Gone, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
PUT /v1/uploads/{id}/bytesProxy mode: the signed URL from proxy.put_url is the credential; PUT it as returned (its origin is PUBLIC_UPLOAD_ORIGIN, deployment-defined; today the api origin). Content-Length must equal size_bytes and Content-Type the session's. The only CORS route: OPTIONS answers 204 for any origin (Allow-Methods PUT, Allow-Headers content-type, Max-Age 600, no credentials) and every PUT response, errors included, carries Access-Control-Allow-Origin *. 401 is invalid_signature or signature_expired.
Operation: putUploadBytes · Auth: UploadUrlSignature
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | upl_… id |
exp | query | yes | Unix seconds |
sig | query | yes | hex hmac-sha256(UPLOAD_SIGNING_SECRET, PUT + LF + upload_id + LF + exp) |
Body (image/*, required)
Responses: 204 Stored and completed, 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 411 LengthRequired, 413 PayloadTooLarge, 415 UnsupportedMediaType, 422 Unprocessable, 503 Unavailable
Hourly and daily usage and the credit balance.
GET /v1/projects/{slug}/usageStorage (live), hourly and daily metering rows; default range the last 30 days, capped at 90
Operation: getUsage · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
from | query | no | ISO 8601 date or date-time; default to minus 30 days |
to | query | no | ISO 8601 date or date-time; default now |
Responses: 200 Usage (Usage), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
The organization's audit log.
GET /v1/admin/audit-logThe caller's organization, newest first; staff actors are hidden
Operation: listAuditLog · Auth: BrowserSession + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (AuditList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 429 TooManyRequests, 503 Unavailable
Checkout and billing portal (Creem, merchant of record), subscription cancellation, invoices and the payment webhooks.
GET /v1/billing/summaryOperation: getBillingSummary · Auth: BrowserSession + WorkosUserId
Responses: 200 The caller's organization billing state (BillingSummary), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/billing/invoicesStripe invoices of the organization (empty without a Stripe customer; always empty for a Creem organization, whose receipts Creem emails and shows in its portal)
Operation: listInvoices · Auth: BrowserSession + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
limit | query | no | 1..100, default 10 |
Responses: 200 Invoices (InvoiceList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/billing/checkout-sessionCheckout for a published plan with the summary's checkout_provider (Stripe Checkout, or Creem when enabled for the organization); redirects must be on DASHBOARD_ORIGIN; 409 already_subscribed; 503 billing_price_mismatch, stripe_request_failed, creem_request_failed
Operation: createCheckoutSession · Auth: BrowserSession + WorkosUserId + RequestSignature
Body (application/json, CheckoutRequest, required)
| Field | Type | Required | Description |
|---|---|---|---|
plan | string | yes | A plan id of contracts/billing/plans.json |
success_url | string | no | |
cancel_url | string | no |
Responses: 200 The Checkout session (StripeSession), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/billing/portal-sessionBilling portal: Creem's customer portal for a Creem organization (id null), else Stripe's (creates the Stripe customer on first use); body optional
Operation: createPortalSession · Auth: BrowserSession + WorkosUserId + RequestSignature
Body (application/json, PortalRequest, optional)
| Field | Type | Required | Description |
|---|---|---|---|
return_url | string | no |
Responses: 200 The portal session (PortalSession), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/billing/webhookStripe events (idempotent on the event id). Body limit 1 MiB. A database failure is 500 so Stripe retries. Internal: infrastructure endpoint, not wrapped by the SDKs.
Operation: postStripeWebhook · Auth: StripeSignature
Body (application/json, StripeEvent, required)
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
type | string | yes | |
created | integer | yes | |
livemode | boolean | no | |
data | object | yes |
Responses: 200 Processed, duplicate or ignored (StripeWebhookResult), 400 BadRequest, 401 Unauthorized, 409 Conflict, 413 PayloadTooLarge, 500 Internal, 503 Unavailable
POST /v1/billing/cancel-subscriptionCreem organizations only: cancel at the end of the paid period (no further charges; the plan stays active until then). 409 use_billing_portal (Stripe organization), no_subscription, already_cancelling; 503 billing_unavailable, creem_request_failed. Body optional: none or an empty JSON object (any field is 400 invalid_request).
Operation: cancelSubscription · Auth: BrowserSession + WorkosUserId + RequestSignature
Body (application/json, CancelSubscriptionRequest, optional)
Responses: 200 The cancellation was accepted; status is the status the api recorded (scheduled_cancel, or canceled/expired when the subscription has ended) (CancelSubscriptionResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/billing/creem/webhookCreem events (idempotent on the event id; credits only from subscription.paid). Body limit 1 MiB. 400 invalid_json, invalid_request (including a created_at not in epoch milliseconds), creem_mode_mismatch; 401 invalid_signature; 503 billing_unavailable, billing_customer_unmapped (no organization, or no metadata.organization_id and the subscription/customer maps to more than one), billing_plan_unmapped (subscription.paid for a product outside the configured CREEM_*_PRODUCT_ID), billing_subscription_mismatch, billing_grant_not_found, creem_request_failed: the event is not recorded, so Creem retries. In test mode an event of an organization outside CREEM_ALLOWED_ORGANIZATION_IDS is recorded and ignored (200). Internal: infrastructure endpoint, not wrapped by the SDKs.
Operation: postCreemWebhook · Auth: CreemSignature
Body (application/json, CreemEvent, required)
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
eventType | string | yes | |
created_at | integer | yes | Epoch milliseconds |
object | object | yes |
Responses: 200 Processed, duplicate or ignored (CreemWebhookResult), 400 BadRequest, 401 Unauthorized, 413 PayloadTooLarge, 500 Internal, 503 Unavailable
Unauthenticated intake.
POST /v1/public/access-requestsMarketing intake: 10/min per IP, 3/h per email. CORS for img2.site and localhost.
Operation: createAccessRequest · Auth: none
| Parameter | In | Required | Description |
|---|---|---|---|
X-Img2site-Client-Ip-Hash | header | no | sha256(visitor ip + IP_HASH_SALT); honoured only from a fully trusted proxy chain |
Body (application/json, AccessRequestInput, required)
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | |
name | string | null | no | |
company | string | null | no | |
url | string | null | no | |
message | string | null | no | |
use_case | string | null | no | |
plan_interest | starter | builder | growth | unsure | null | no | |
locale | string | null | no | |
source_ip_hash | string | null | no | Accepted and ignored |
Responses: 202 Accepted (AccessRequestAccepted), 400 BadRequest, 413 PayloadTooLarge, 429 TooManyRequests
Public service status.
GET /v1/public/statusLast 24 h of status samples per service plus open and recent incidents (Cache-Control public, max-age=30)
Operation: getPublicStatus · Auth: none
Responses: 200 Status (PublicStatus)
img2site staff console only.
GET /v1/staff/access-requestsOperation: staffListAccessRequests · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
status | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (StaffAccessRequestList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/organizations/{organization_id}One organization with its quota state; audited as staff.organization.read
Operation: staffGetOrganization · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
organization_id | path | yes | org_… id |
Responses: 200 The organization (StaffOrganization), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PATCH /v1/staff/organizations/{organization_id}billing_exempt (unlimited free usage); audited as staff.organization.billing_exempt with before/after
Operation: staffUpdateOrganization · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
organization_id | path | yes | org_… id |
Body (application/json, StaffOrganizationPatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
billing_exempt | boolean | yes | true = unlimited free usage (no quota refusal, no daily charge; usage is still recorded) and clears quota_exceeded_at at once; false records billing_exempt_until = now(), usage while exempt is never billed |
Responses: 200 The organization (StaffOrganization), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/projectsAll organizations' projects, keyset
Operation: staffListProjects · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
organization_id | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (StaffProjectList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/projects/{slug}One project (any organization); audited as staff.project.read
Operation: staffGetProject · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Responses: 200 The project (StaffProject), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
PATCH /v1/staff/projects/{slug}status (staff suspension), custom_domains, storage_hard_limit_bytes (null = plan allowance), pricing_multiplier, nudity_auto_block (true -> false releases the scanner's unreviewed nudity blocks)
Operation: staffUpdateProject · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
Body (application/json, StaffProjectPatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
status | active | suspended | no | |
custom_domains | string[] | no | Hostnames, at most 32, not under the delivery root domain, unique across projects |
storage_hard_limit_bytes | integer | null | no | |
pricing_multiplier | number | no | |
nudity_auto_block | boolean | no | true -> false also unblocks, in the same transaction, every file in the project blocked `nudity-v1:*` (not `nudity-v1:staff_rejected`) whose nudity check is still unreviewed; the checks stay unreviewed |
Responses: 200 The project (StaffProjectUpdated), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/projects/{slug}/filesOperation: staffListProjectFiles · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
status | query | no | |
q | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (StaffFileList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/projects/{slug}/uploadsOperation: staffListProjectUploads · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
status | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (StaffUploadList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/projects/{slug}/usageOperation: staffGetProjectUsage · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
slug | path | yes | Project slug (DNS label) |
from | query | no | ISO 8601 date or date-time; default to minus 30 days |
to | query | no | ISO 8601 date or date-time; default now |
Responses: 200 Usage (Usage), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
POST /v1/staff/files/{fileId}/blockfiles.status blocked + purge + file.blocked webhook; removes watermark presets using the file
Operation: staffBlockFile · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
fileId | path | yes | file_… id |
Body (application/json, BlockRequest, required)
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | yes |
Responses: 200 The file (ModerationResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
POST /v1/staff/files/{fileId}/unblockBack to ready + purge + file.unblocked webhook; approves the file's checks. Body optional.
Operation: staffUnblockFile · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
fileId | path | yes | file_… id |
Body (application/json, UnblockRequest, optional)
| Field | Type | Required | Description |
|---|---|---|---|
note | string | no | Stored on the check_reviews row the unblock records |
Responses: 200 The file (ModerationResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/moderation-notesOperation: staffListModerationNotes · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
project_slug | query | no | |
file_slug | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (StaffModerationNoteList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/reconciliationmetering_daily state per UTC day for the last 7 days (today included)
Operation: staffGetReconciliation · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
project_slug | query | no |
Responses: 200 Reconciliation state (Reconciliation), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
Moderation scanner results and staff review (staff only).
GET /v1/staff/checksScanner checks, keyset, newest first. Audited as staff.checks.list with metadata.check_ids (the returned page's ids), which also covers the thumbnails the queue shows (staffGetCheckImage size=thumb).
Operation: staffListChecks · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
status | query | no | |
review | query | no | |
flag | query | no | |
project_slug | query | no | |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 One page (CheckList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/checks/{id}Operation: staffGetCheck · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | chk_… id |
Responses: 200 The check and its reviews (CheckDetail), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable
GET /v1/staff/checks/{id}/imageA webp preview of the content the check scanned (the sanitized content object of its sha256, not the file's current revision), resized to fit a size x size box (thumb 480, large 1600; smaller sources are enlarged). Served even while the file is blocked. One render per api process with up to 32 waiting (503 preview_busy beyond); sources above 64 MiB are 413 preview_too_large; unrenderable content is 422 preview_unavailable; an unknown check is 404 not_found and a check whose content object is no longer stored 404 content_missing; a retryable store failure or a source read not finished within 15 s is 503 storage_unavailable. Rendered previews are cached per api process (keyed by sha256 and size): a hit reads nothing and takes no slot. Only size=large is audited, as staff.check.image (metadata.size) on success, cache hits included; thumb renders get no row of their own (the queue listing's staff.checks.list row lists the page's check ids in metadata.check_ids).
Operation: staffGetCheckImage · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | chk_… id |
size | query | no | Default large; anything else is 400 invalid_request |
Responses: 200 The preview. Cache-Control private, no-store; X-Content-Type-Options nosniff; Content-Disposition inline., 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 422 Unprocessable, 429 TooManyRequests, 503 Unavailable
POST /v1/staff/checks/{id}/reviewapproved unblocks a nudity-v1 block; rejected blocks a served file. Only flagged checks (409 check_not_flagged).
Operation: staffReviewCheck · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | chk_… id |
Body (application/json, CheckReviewRequest, required)
| Field | Type | Required | Description |
|---|---|---|---|
decision | approved | rejected | yes | |
note | string | no |
Responses: 200 The review (CheckReviewResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
Manual status incidents (staff only).
GET /v1/staff/incidentsEvery incident, newest start first (keyset); audited as staff.incidents.list
Operation: staffListIncidents · Auth: Staff + WorkosUserId
| Parameter | In | Required | Description |
|---|---|---|---|
active_since | query | no | Only incidents still open, or started or ended after this ISO-8601 instant |
limit | query | no | 1..100, default 50 |
cursor | query | no | Opaque next_cursor of the previous page; a tampered cursor is 400 invalid_cursor |
Responses: 200 A page of incidents (IncidentList), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 429 TooManyRequests, 503 Unavailable
POST /v1/staff/incidentsManual incident for the status page (auto false)
Operation: staffCreateIncident · Auth: Staff + WorkosUserId + RequestSignature
Body (application/json, IncidentCreate, required)
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | |
services | string[] | yes | |
severity | minor | major | critical | yes | |
started_at | string | no | |
body | string | yes |
Responses: 201 Created (Incident), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable
PATCH /v1/staff/incidents/{id}ended_at and/or body of a manual incident; automatic incidents are 409 incident_automatic
Operation: staffUpdateIncident · Auth: Staff + WorkosUserId + RequestSignature
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | inc_… id |
Body (application/json, IncidentPatch, required)
| Field | Type | Required | Description |
|---|---|---|---|
ended_at | string | null | no | |
body | string | no |
Responses: 200 Updated (Incident), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable