Reference · REST API

REST API reference.

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.

Base URLs

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.

Authentication schemes

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.

Authentication schemes
SchemeTransportDescription
ApiKeyHTTP 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.
ApiKeyHeaderheader X-Api-KeySame key as ApiKey, in X-Api-Key (checked first when both are sent).
BrowserSessionHTTP 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.
WorkosUserIdheader x-workos-user-idMust 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.
RequestSignatureheader X-Request-SignatureBrowser 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.
StaffHTTP 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.
StripeSignatureheader Stripe-Signaturet=…,v1=… over the raw body (300 s tolerance).
CreemSignatureheader creem-signatureLowercase hex hmac-sha256(CREEM_WEBHOOK_SECRET, raw body); no timestamp.
StorageEventSignatureheader X-Bz-Event-Notification-Signaturev1=hex hmac-sha256(STORAGE_EVENTS_SECRET, raw body).
UploadUrlSignaturequery sigSigned proxy URL (exp and sig query parameters) from proxy.put_url.
LibrarySessionHTTP 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).
EmbedUrlSignaturequery sSigned embed URL (o, exp, kid, s) from embed_url: s = base64url hmac-sha256(EMBED_URL_SECRET[kid], embed1|project|library|o|exp).

Error envelope

Every error response has this shape. Branch on code; message is for humans.

Error envelope fields
FieldTypeRequiredDescription
error.codestringyes
error.messagestringyes
error.detailsanynoRoute-specific; zod issues for invalid_request

Example

{
  "error": {
    "code": "file_too_large",
    "message": "…",
    "details": { "max_bytes": 100000000 }
  }
}

Shared error responses

Shared error responses and their codes
ResponseCodes
BadRequestinvalid_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)
Unauthorizedunauthorized, invalid_request_signature, invalid_signature, signature_expired
Forbiddenbrowser_session_required, api_key_required, forbidden (scope), staff_only, staff_only_field, project_suspended, project_suspended_by_staff, storage_quota_exceeded, raw_download_disabled
NotFoundnot_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)
Conflictproject_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
Goneupload_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.
LengthRequiredlength_required
PayloadTooLargepayload_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)
UnsupportedMediaTypecontent_type_mismatch
Unprocessablesha256_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)
TooManyRequestsrate_limited, with Retry-After (seconds)
Internalinternal (the cause is logged, never returned)
Unavailableauth_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

probes

Liveness and readiness of the api process. Infrastructure only.

GET /healthz

Liveness (the process answers) Internal: infrastructure endpoint, not wrapped by the SDKs.

Operation: getHealthz · Auth: none

Responses: 200 Alive (Healthz)

GET /readyz

Readiness (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)

internal

Called by the storage provider, not by customers.

POST /v1/internal/storage-events

B2 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)

postStorageEvents request body fields
FieldTypeRequiredDescription
eventsobject[]yes

Responses: 200 Counted (StorageEventsResult), 400 BadRequest, 401 Unauthorized, 413 PayloadTooLarge, 500 Internal

identity

The signed-in dashboard user and their organization.

GET /v1/organizations

Only 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/organizations

Staff 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)

createOrganization request body fields
FieldTypeRequiredDescription
namestringyes

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

Projects own files, keys, presets and a delivery host.

GET /v1/projects

Projects of the caller's organization, keyset, newest first

Operation: listProjects · Auth: BrowserSession + WorkosUserId

listProjects parameters
ParameterInRequiredDescription
limitqueryno1..100, default 50
cursorquerynoOpaque 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/projects

Create a project and its first signing key (body limit 256 KiB)

Operation: createProject · Auth: BrowserSession + WorkosUserId + RequestSignature

Body (application/json, ProjectCreate, required)

createProject request body fields
FieldTypeRequiredDescription
project_slugstringnoRequired (or its deprecated alias slug); DNS label, not reserved
slugstringno
organization_idstring | nullnoStaff only: create in another existing organization
namestringno
delivery_accesspublic | protectedno
cors_allowed_originsstring[]no
resolution_presetsobjectno
allowed_transform_chainsstring[]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)

getProject parameters
ParameterInRequiredDescription
slugpathyesProject 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

updateProject parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, ProjectPatch, required)

updateProject request body fields
FieldTypeRequiredDescription
namestringno
delivery_accesspublic | protectedno
cors_allowed_originsstring[]no
resolution_presetsobjectno
allowed_transform_chainsstring[]no
statusactive | suspendedno
default_watermark_preset_idstring | nullno
project_default_watermarkstring | nullnoDeprecated alias; an empty string clears
raw_download_enabledbooleanno
callback_urlstring | nullno
rotate_callback_secretbooleanno
storage_hard_limit_bytesinteger | nullnoStaff only
pricing_multipliernumbernoStaff 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

suspendProject parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Responses: 204 Suspended (repeat calls too), 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

keys

Project API keys (created in the dashboard) and key introspection for SDKs.

GET /v1/key

The 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}/keys

Every key of the project, newest first (bounded, next_cursor null)

Operation: listApiKeys · Auth: BrowserSession + WorkosUserId

listApiKeys parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Responses: 200 The keys (ApiKeyList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

POST /v1/projects/{slug}/keys

New key; plaintext_secret is returned only here. Omitted scopes = full project access.

Operation: createApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature

createApiKey parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, ApiKeyCreate, required)

createApiKey request body fields
FieldTypeRequiredDescription
namestringyes
scopesfiles: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}/revoke

Operation: revokeApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature

revokeApiKey parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
keyIdpathyeskey_… 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}/rotate

New key with the same name and scopes; the old one is revoked in the same transaction

Operation: rotateApiKey · Auth: BrowserSession + WorkosUserId + RequestSignature

rotateApiKey parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
keyIdpathyeskey_… id

Responses: 200 The new key (ApiKeyRotated), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

signing

Delivery signing keys and signed delivery URLs.

GET /v1/projects/{slug}/signing-keys

kid, status and retire_after of every signing key (never secrets)

Operation: listSigningKeys · Auth: BrowserSession + WorkosUserId

listSigningKeys parameters
ParameterInRequiredDescription
slugpathyesProject 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/rotate

New active kid; the previous one keeps verifying for 24 h

Operation: rotateSigningKey · Auth: BrowserSession + WorkosUserId + RequestSignature

rotateSigningKey parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Responses: 200 Rotated (SigningKeyRotation), 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

POST /v1/projects/{slug}/signed-url

Signed 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)

createSignedUrl parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, SignedUrlRequest, required)

createSignedUrl request body fields
FieldTypeRequiredDescription
file_slugstringyes
transform_chainstring | nullnoA delivery chain; the deprecated values originals and download/raw imply the kind
kindderived | originals | rawnoDefault: derived with a chain, else originals
expires_in_secondsinteger | nullno60..3600, default 300

Responses: 200 The URL (SignedUrl), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

presets

Watermark presets; each image is a ready file of the same project.

GET /v1/projects/{slug}/watermark-presets

Every preset of the project (bounded; no next_cursor)

Operation: listWatermarkPresets · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)

listWatermarkPresets parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Responses: 200 The presets (WatermarkPresetList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

PUT /v1/projects/{slug}/watermark-presets

Creates 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)

createWatermarkPreset parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, WatermarkPresetInput, required)

createWatermarkPreset request body fields
FieldTypeRequiredDescription
namestringyes
file_slugstringyesOne of the project's ready files
x_offsetnumberno
y_offsetnumberno
opacitynumberno0..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)

getWatermarkPreset parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyeswm_… 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)

saveWatermarkPreset parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyeswm_… id

Body (application/json, WatermarkPresetInput, required)

saveWatermarkPreset request body fields
FieldTypeRequiredDescription
namestringyes
file_slugstringyesOne of the project's ready files
x_offsetnumberno
y_offsetnumberno
opacitynumberno0..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)

deleteWatermarkPreset parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyeswm_… id

Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

recipes

Named transform chains.

GET /v1/projects/{slug}/recipes

Every recipe of the project (bounded, next_cursor null)

Operation: listRecipes · Auth: BrowserSession + WorkosUserId or ApiKey (recipes:read) or ApiKeyHeader (recipes:read)

listRecipes parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Responses: 200 The recipes (RecipeList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

PUT /v1/projects/{slug}/recipes

Creates a new recipe on every call (not idempotent)

Operation: createRecipe · Auth: BrowserSession + WorkosUserId + RequestSignature or ApiKey (recipes:write) or ApiKeyHeader (recipes:write)

createRecipe parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, RecipeInput, required)

createRecipe request body fields
FieldTypeRequiredDescription
namestringyes
transform_chainstringyes
schema_version1no

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)

getRecipe parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesrcp_… 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)

saveRecipe parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesrcp_… id

Body (application/json, RecipeInput, required)

saveRecipe request body fields
FieldTypeRequiredDescription
namestringyes
transform_chainstringyes
schema_version1no

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)

deleteRecipe parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesrcp_… id

Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

files

Published files of a project.

GET /v1/projects/{slug}/files

Keyset, 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)

listFiles parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
statusqueryno
qquerynoSlug prefix
libraryquerynoLibrary 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.
folderquerynoFolder path (normalized; empty = the library root); requires library (400 folder_requires_library); malformed is 400 invalid_folder
recursivequerynoWith folder: include the whole subtree. Default false.
searchquerynoAt 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_locationquerynoFiles with (true) or without (false) coordinates; requires library
bboxquerynowest,south,east,north in degrees; west > east crosses the antimeridian; requires library; malformed or out of range is 400 invalid_bbox
sortquerynouploaded (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).
uploaderquerynoA 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 }.
includequerynoComma list of extra fields; only location is known (anything else is 400 invalid_request)
limitqueryno1..100, default 50
cursorquerynoOpaque 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)

getFile parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fileSlugpathyes
includequerynoComma 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)

updateFile parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fileSlugpathyes

Body (application/json, FilePatch, required)

updateFile request body fields
FieldTypeRequiredDescription
titlestring | nullnoControl and bidi characters stripped, trimmed, cut to 200 characters; empty or null clears
alt_textstring | nullnoAs title, cut to 1000 characters
folderstringnoNeeds MEDIA_LIBRARY_WRITES; normalized (400 invalid_folder)
librarystringnoLibrary 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)

deleteFile parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fileSlugpathyes

Responses: 204 Deleted, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

POST /v1/projects/{slug}/files/{fileSlug}/moderation-notes

One note per (project, file slug); a second replaces it

Operation: createModerationNote · Auth: BrowserSession + WorkosUserId + RequestSignature

createModerationNote parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fileSlugpathyes

Body (application/json, ModerationNoteInput, required)

createModerationNote request body fields
FieldTypeRequiredDescription
notestringno
bodystringno

Responses: 201 The note (ModerationNote), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

POST /v1/projects/{slug}/files/move

Bulk 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)

moveFiles parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, FileMove, required)

moveFiles request body fields
FieldTypeRequiredDescription
file_slugsstring[]yes1..1000; duplicates ignored
folderstringyes
librarystringnoLibrary 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

libraries

GET /v1/projects/{slug}/libraries

Live 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)

listLibraries parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
limitqueryno1..100, default 50
cursorquerynoOpaque 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}/libraries

Needs 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)

createLibrary parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, LibraryCreate, required)

createLibrary request body fields
FieldTypeRequiredDescription
slugstringyes
namestringyes1..120 characters after trim
allowed_originsstring[]noUp 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)

getLibrary parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
libpathyesLibrary 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)

updateLibrary parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
libpathyesLibrary slug, or default for the project's built-in library

Body (application/json, LibraryPatch, required)

updateLibrary request body fields
FieldTypeRequiredDescription
namestringno
allowed_originsstring[]no
delivery_accessanynoPhase 2. Known but refused in phase 1: any value is 400 not_supported (details.fields).
map_enabledanynoPhase 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)

deleteLibrary parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
libpathyesLibrary 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

library-sessions

POST /v1/projects/{slug}/library-sessions

Mint 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)

createLibrarySession parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, LibrarySessionCreate, required)

createLibrarySession request body fields
FieldTypeRequiredDescription
librarystringyesLibrary slug, or default (required)
folderstringnoScope: this folder and its subtree (normalized; 400 invalid_folder). Default '' = the whole library.
permissionsbrowse | browse_own | upload[]yesbrowse = the whole scope, browse_own = only this subject's uploads (exclusive with browse), upload; upload alone is the upload-only picker
originstringyesThe page that embeds the picker: an https origin, or an exact http://localhost:<port> / http://127.0.0.1:<port> registered as such
subjectstringyesThe customer's pseudonymous id of its end user (1..128 characters)
expires_inintegerno60..3600 seconds, default 3600
max_file_bytesintegernoPer-file cap of session uploads; default min(25 MiB, the proxy limit), at most the proxy limit
max_uploadsintegernoUploads one picker session may create (1..1000, default 100; per pod)
refresh_ofstringnoThe 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/files

The 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

listLibrarySessionFiles parameters
ParameterInRequiredDescription
searchquerynoAt least 2 characters after trim (400 invalid_search); case-insensitive literal substring of title, original_filename and slug
limitqueryno1..60, default 40
cursorquerynoOpaque 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/uploads

Upload 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)

createLibrarySessionUpload request body fields
FieldTypeRequiredDescription
content_typestringyes
size_bytesintegeryes
original_filenamestring | nullno
titlestring | nullno

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

getLibrarySessionUpload parameters
ParameterInRequiredDescription
idpathyesupl_… id

Responses: 200 The upload (LibraryUpload), 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 429 TooManyRequests, 503 Unavailable

POST /v1/library/uploads/{id}/complete

Idempotent 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

completeLibrarySessionUpload parameters
ParameterInRequiredDescription
idpathyesupl_… 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

getLibraryEmbed parameters
ParameterInRequiredDescription
projectpathyes
librarypathyesLibrary slug or default
oqueryyesThe embedding origin, exactly as signed
expqueryyes
kidqueryyes
squeryyes

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

getLibraryEmbedAsset parameters
ParameterInRequiredDescription
filepathyes

Responses: 200 The asset, 404 NotFound, 503 Unavailable

uploads

Upload sessions: create, send bytes (direct, multipart or proxy), complete, read.

GET /v1/projects/{slug}/uploads

Upload sessions, keyset, newest first

Operation: listUploads · Auth: BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read)

listUploads parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
statusqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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}/uploads

Create 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)

createUpload parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, UploadCreate, required)

createUpload request body fields
FieldTypeRequiredDescription
file_slugstringnoDefault: the upload id lower-cased
content_typeimage/jpeg | image/png | image/webp | image/avif | image/gif | image/tiff | image/bmp | image/svg+xmlyesimage/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_bytesintegeryes
sha256stringno
modedirect | proxyno
partsintegerno1..10000; more than 1 selects S3 multipart (direct mode only, parts of at least 5 MiB)
librarystringnoLibrary slug or default; needs MEDIA_LIBRARY_WRITES. Omitted = a re-upload keeps the file's library (a new file: default).
folderstringnoNeeds MEDIA_LIBRARY_WRITES; normalized (400 invalid_folder). Omitted = keep (a new file: the root).
titlestring | nullnoAlways accepted; cleaned and cut to 200 characters
original_filenamestring | nullnoAlways accepted; cleaned and cut to 255 UTF-8 bytes
subjectstringnoThe 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)

getUpload parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesupl_… 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)

abortUpload parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesupl_… 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}/complete

Optional 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)

completeUpload parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesupl_… id

Body (application/json, UploadComplete, optional)

completeUpload request body fields
FieldTypeRequiredDescription
partsobject[]noRequired 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}/parts

Resume 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)

refreshUploadParts parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
idpathyesupl_… id

Body (application/json, UploadPartsRequest, optional)

refreshUploadParts request body fields
FieldTypeRequiredDescription
part_numbersinteger[]no1..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}/bytes

Proxy 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

putUploadBytes parameters
ParameterInRequiredDescription
idpathyesupl_… id
expqueryyesUnix seconds
sigqueryyeshex 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

usage

Hourly and daily usage and the credit balance.

GET /v1/projects/{slug}/usage

Storage (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)

getUsage parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fromquerynoISO 8601 date or date-time; default to minus 30 days
toquerynoISO 8601 date or date-time; default now

Responses: 200 Usage (Usage), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

audit

The organization's audit log.

GET /v1/admin/audit-log

The caller's organization, newest first; staff actors are hidden

Operation: listAuditLog · Auth: BrowserSession + WorkosUserId

listAuditLog parameters
ParameterInRequiredDescription
limitqueryno1..100, default 50
cursorquerynoOpaque 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

billing

Checkout and billing portal (Creem, merchant of record), subscription cancellation, invoices and the payment webhooks.

GET /v1/billing/invoices

Stripe 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

listInvoices parameters
ParameterInRequiredDescription
limitqueryno1..100, default 10

Responses: 200 Invoices (InvoiceList), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

POST /v1/billing/checkout-session

Checkout 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)

createCheckoutSession request body fields
FieldTypeRequiredDescription
planstringyesA plan id of contracts/billing/plans.json
success_urlstringno
cancel_urlstringno

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-session

Billing 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)

createPortalSession request body fields
FieldTypeRequiredDescription
return_urlstringno

Responses: 200 The portal session (PortalSession), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

POST /v1/billing/webhook

Stripe 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)

postStripeWebhook request body fields
FieldTypeRequiredDescription
idstringyes
typestringyes
createdintegeryes
livemodebooleanno
dataobjectyes

Responses: 200 Processed, duplicate or ignored (StripeWebhookResult), 400 BadRequest, 401 Unauthorized, 409 Conflict, 413 PayloadTooLarge, 500 Internal, 503 Unavailable

POST /v1/billing/cancel-subscription

Creem 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/webhook

Creem 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)

postCreemWebhook request body fields
FieldTypeRequiredDescription
idstringyes
eventTypestringyes
created_atintegeryesEpoch milliseconds
objectobjectyes

Responses: 200 Processed, duplicate or ignored (CreemWebhookResult), 400 BadRequest, 401 Unauthorized, 413 PayloadTooLarge, 500 Internal, 503 Unavailable

public

Unauthenticated intake.

POST /v1/public/access-requests

Marketing intake: 10/min per IP, 3/h per email. CORS for img2.site and localhost.

Operation: createAccessRequest · Auth: none

createAccessRequest parameters
ParameterInRequiredDescription
X-Img2site-Client-Ip-Hashheadernosha256(visitor ip + IP_HASH_SALT); honoured only from a fully trusted proxy chain

Body (application/json, AccessRequestInput, required)

createAccessRequest request body fields
FieldTypeRequiredDescription
emailstringyes
namestring | nullno
companystring | nullno
urlstring | nullno
messagestring | nullno
use_casestring | nullno
plan_intereststarter | builder | growth | unsure | nullno
localestring | nullno
source_ip_hashstring | nullnoAccepted and ignored

Responses: 202 Accepted (AccessRequestAccepted), 400 BadRequest, 413 PayloadTooLarge, 429 TooManyRequests

status

Public service status.

GET /v1/public/status

Last 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)

staff

img2site staff console only.

GET /v1/staff/access-requests

Operation: staffListAccessRequests · Auth: Staff + WorkosUserId

staffListAccessRequests parameters
ParameterInRequiredDescription
statusqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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

staffGetOrganization parameters
ParameterInRequiredDescription
organization_idpathyesorg_… 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

staffUpdateOrganization parameters
ParameterInRequiredDescription
organization_idpathyesorg_… id

Body (application/json, StaffOrganizationPatch, required)

staffUpdateOrganization request body fields
FieldTypeRequiredDescription
billing_exemptbooleanyestrue = 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/projects

All organizations' projects, keyset

Operation: staffListProjects · Auth: Staff + WorkosUserId

staffListProjects parameters
ParameterInRequiredDescription
organization_idqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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

staffGetProject parameters
ParameterInRequiredDescription
slugpathyesProject 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

staffUpdateProject parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)

Body (application/json, StaffProjectPatch, required)

staffUpdateProject request body fields
FieldTypeRequiredDescription
statusactive | suspendedno
custom_domainsstring[]noHostnames, at most 32, not under the delivery root domain, unique across projects
storage_hard_limit_bytesinteger | nullno
pricing_multipliernumberno
nudity_auto_blockbooleannotrue -> 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}/files

Operation: staffListProjectFiles · Auth: Staff + WorkosUserId

staffListProjectFiles parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
statusqueryno
qqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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}/uploads

Operation: staffListProjectUploads · Auth: Staff + WorkosUserId

staffListProjectUploads parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
statusqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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}/usage

Operation: staffGetProjectUsage · Auth: Staff + WorkosUserId

staffGetProjectUsage parameters
ParameterInRequiredDescription
slugpathyesProject slug (DNS label)
fromquerynoISO 8601 date or date-time; default to minus 30 days
toquerynoISO 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}/block

files.status blocked + purge + file.blocked webhook; removes watermark presets using the file

Operation: staffBlockFile · Auth: Staff + WorkosUserId + RequestSignature

staffBlockFile parameters
ParameterInRequiredDescription
fileIdpathyesfile_… id

Body (application/json, BlockRequest, required)

staffBlockFile request body fields
FieldTypeRequiredDescription
reasonstringyes

Responses: 200 The file (ModerationResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

POST /v1/staff/files/{fileId}/unblock

Back to ready + purge + file.unblocked webhook; approves the file's checks. Body optional.

Operation: staffUnblockFile · Auth: Staff + WorkosUserId + RequestSignature

staffUnblockFile parameters
ParameterInRequiredDescription
fileIdpathyesfile_… id

Body (application/json, UnblockRequest, optional)

staffUnblockFile request body fields
FieldTypeRequiredDescription
notestringnoStored 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-notes

Operation: staffListModerationNotes · Auth: Staff + WorkosUserId

staffListModerationNotes parameters
ParameterInRequiredDescription
project_slugqueryno
file_slugqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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/reconciliation

metering_daily state per UTC day for the last 7 days (today included)

Operation: staffGetReconciliation · Auth: Staff + WorkosUserId

staffGetReconciliation parameters
ParameterInRequiredDescription
project_slugqueryno

Responses: 200 Reconciliation state (Reconciliation), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

checks

Moderation scanner results and staff review (staff only).

GET /v1/staff/checks

Scanner 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

staffListChecks parameters
ParameterInRequiredDescription
statusqueryno
reviewqueryno
flagqueryno
project_slugqueryno
limitqueryno1..100, default 50
cursorquerynoOpaque 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

staffGetCheck parameters
ParameterInRequiredDescription
idpathyeschk_… id

Responses: 200 The check and its reviews (CheckDetail), 401 Unauthorized, 403 Forbidden, 404 NotFound, 429 TooManyRequests, 503 Unavailable

GET /v1/staff/checks/{id}/image

A 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

staffGetCheckImage parameters
ParameterInRequiredDescription
idpathyeschk_… id
sizequerynoDefault 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}/review

approved unblocks a nudity-v1 block; rejected blocks a served file. Only flagged checks (409 check_not_flagged).

Operation: staffReviewCheck · Auth: Staff + WorkosUserId + RequestSignature

staffReviewCheck parameters
ParameterInRequiredDescription
idpathyeschk_… id

Body (application/json, CheckReviewRequest, required)

staffReviewCheck request body fields
FieldTypeRequiredDescription
decisionapproved | rejectedyes
notestringno

Responses: 200 The review (CheckReviewResult), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable

incidents

Manual status incidents (staff only).

GET /v1/staff/incidents

Every incident, newest start first (keyset); audited as staff.incidents.list

Operation: staffListIncidents · Auth: Staff + WorkosUserId

staffListIncidents parameters
ParameterInRequiredDescription
active_sincequerynoOnly incidents still open, or started or ended after this ISO-8601 instant
limitqueryno1..100, default 50
cursorquerynoOpaque 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/incidents

Manual incident for the status page (auto false)

Operation: staffCreateIncident · Auth: Staff + WorkosUserId + RequestSignature

Body (application/json, IncidentCreate, required)

staffCreateIncident request body fields
FieldTypeRequiredDescription
titlestringyes
servicesstring[]yes
severityminor | major | criticalyes
started_atstringno
bodystringyes

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

staffUpdateIncident parameters
ParameterInRequiredDescription
idpathyesinc_… id

Body (application/json, IncidentPatch, required)

staffUpdateIncident request body fields
FieldTypeRequiredDescription
ended_atstring | nullno
bodystringno

Responses: 200 Updated (Incident), 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound, 409 Conflict, 413 PayloadTooLarge, 429 TooManyRequests, 503 Unavailable