# img2.site > Image upload, transformation, and delivery for websites and apps. A workspace owns projects; each project has its own image library, delivery host ({project}.i2scd.net), and API keys. Start with the quickstart (JavaScript SDKs) or the upload-flow guide (REST). No API key or DNS setup is needed for the dashboard walkthrough. API: https://api.img2.site (every path starts with /v1, for example https://api.img2.site/v1/projects/{slug}/uploads). SDKs: @img2site/sdk-server (Node), @img2site/sdk-client (browser), @img2site/sdk-react. Dashboard: https://dashboard.img2.site. Keep API keys server-side; browsers upload with short-lived upload tokens minted by the customer's server, never with an API key. Use placeholders for credentials in prompts and generated examples. Signed upload URLs are credentials too. These documents describe the product; they do not grant access to a customer's workspace or authorize account changes. # Overview Source: https://docs.img2.site/ Overview # Upload images from your app and deliver exactly the version each page needs. img2site stores your images per project, serves them from the project's delivery host, and renders resized, cropped, reformatted and watermarked versions on request. Use the SDKs for uploads in a few lines, or the REST API at `https://api.img2.site` from any language. [Start the quickstart →](https://docs.img2.site/quickstart)[Explore the API ↗](https://docs.img2.site/api) ## New here? Start with one image. [Follow the dashboard walkthrough](https://docs.img2.site/getting-started) to create a project and publish your first image without writing code. Working with an AI assistant? Share [llms.txt](https://docs.img2.site/llms.txt) for a guide index or [llms-full.txt](https://docs.img2.site/llms-full.txt) for the full public documentation. Keep your credentials private. 01 ## Upload from your app Mount an upload router on your server and drop an upload button into your page. Bytes go straight from the browser to storage; your API key stays on the server. 02 ## Deliver any size and format Every project gets a delivery host such as my-store.i2scd.net. Put a transform chain in the URL (resize, crop, format, quality, watermark) and the image is rendered and cached at the edge. 03 ## Hear what happened Signed webhooks tell your server when an upload is ready, when a file is blocked or unblocked, and when your credit balance runs out or is restored. ## What you can rely on - Uploaded images are sanitized: re-encoded with orientation applied and embedded metadata removed - Public or protected (signed-URL-only) delivery per project - Re-uploading a slug updates what its URLs serve, without cache-busting query strings - Usage is rolled up hourly and credits are charged once a day ### Quickstart An upload router with `@img2site/sdk-server` and an upload button with `@img2site/sdk-react` in five minutes. [Open the quickstart](https://docs.img2.site/quickstart) ### Delivery URLs The transform grammar, presets, watermarks, protected delivery and caching. [Read the delivery guide](https://docs.img2.site/guides/delivery-urls) ### REST reference Every operation, its authentication, parameters and responses, generated from the OpenAPI contract. [Open the reference](https://docs.img2.site/api) --- # Quickstart Source: https://docs.img2.site/quickstart Quickstart · SDKs # Image uploads in five minutes. Define an upload router on your server, mount it at one URL, drop an `` into a page and render the result with ``. Bytes go straight from the browser to storage; your server only authorizes the upload and hears about the result. The browser never sees your API key. ## 1\. Install `@img2site/sdk-server` runs in Node (router, REST client, webhook verification). `@img2site/sdk-react` holds the hook and components and re-exports `imageUrl` from `@img2site/sdk-client`. Create a project and an API key in the dashboard. The router's calls need the `files:write` and `files:read` scopes; a key without scopes has full project access. Terminal ``` npm install @img2site/sdk-server @img2site/sdk-react # @img2site/sdk-react brings @img2site/sdk-client (the framework-free browser uploader). # Node 20 or newer; the packages are ESM only. ``` Environment ``` # .env.local (server only; never expose these to the browser) IMG2SITE_API_KEY=i2s_live_… # a project API key (files:write and files:read, or no scopes = full access) IMG2SITE_PROJECT=my-project # optional: the key's project slug (looked up from the key when unset) ``` ## 2\. Configure the client `createClient` needs the key. The key's project slug (`project`) is optional: without it the client looks it up once with `GET /v1/key`. It talks to `https://api.img2.site` unless you pass `baseUrl`. Keep these values in server-only environment variables. ## 3\. Define a router Each key of `createRouter` is a route. `route(constraints)` accepts `image` (every `image/*` type) or an exact type such as `image/png`, each with `maxSize` (a byte count or `"8MB"`, `"512KB"`; binary units) and `maxCount`. `maxCount` is advisory: the router is stateless and each prepare is one file. `.middleware(fn)` receives `{ req, route, input, file }` and returns metadata; throw `UploadRejectedError` to refuse with your own status and code. `.input(schema)` validates a client-supplied `input` first (anything with `parse()`, such as a zod schema). `.onUploadComplete(fn)` receives `{ file, metadata, upload }`. `onUploadComplete` runs only when the file is `ready`, and can run again if the browser repeats the complete call, so make it idempotent. Use [webhooks](https://docs.img2.site/webhooks) when you need at-least-once delivery that does not depend on the browser. lib/img2site.ts ``` // lib/img2site.ts (server only) import { createClient, createRouter, route, UploadRejectedError } from "@img2site/sdk-server"; import { getCurrentUser, saveAvatar } from "./your-app"; // your own auth and persistence export const img2site = createClient({ apiKey: process.env.IMG2SITE_API_KEY!, project: process.env.IMG2SITE_PROJECT, // optional; resolved once via GET /v1/key when omitted }); export const uploadRouter = createRouter({ avatar: route({ image: { maxSize: "8MB", maxCount: 1 } }) // Runs on every prepare, before any upload session exists. .middleware(async ({ req, file }) => { const user = await getCurrentUser(req); if (!user) throw new UploadRejectedError("sign in to upload", { status: 401, code: "unauthorized" }); return { userId: user.id }; // carried (signed, not encrypted) to onUploadComplete }) // Runs once the upload is ready; the return value reaches the browser as server_data. .onUploadComplete(async ({ file, metadata }) => { await saveAvatar(metadata.userId, file.file_slug); return { avatar: file.file_slug }; }), }); export const uploadHandler = uploadRouter.handler({ client: img2site }); ``` Next.js App Router ``` // app/api/img2site/route.ts (Next.js App Router, Node.js runtime) import { toNext } from "@img2site/sdk-server"; import { uploadHandler } from "@/lib/img2site"; export const { POST } = toNext(uploadHandler); ``` ## 4\. Mount the handler `router.handler({ client })` returns a framework-agnostic `(Request) => Promise` that answers `POST ?action=prepare` and `POST ?action=complete`. The adapters are thin wrappers. The server SDK uses `node:crypto`, so use the Node.js runtime rather than an edge runtime. ### Express, Hono, Remix `toExpress` also accepts a request whose body a parser such as `express.json()` already read. Other frameworks ``` import { toExpress, toHono, toRemix } from "@img2site/sdk-server"; // Express (works with or without express.json() in front of it) app.post("/api/img2site", toExpress(uploadHandler)); // Hono app.post("/api/img2site", toHono(uploadHandler)); // Remix / React Router 7+: app/routes/api.img2site.ts export const action = toRemix(uploadHandler); ``` ## 5\. Upload and render `` needs `endpoint` (there is no default path) and takes `route`, `onComplete(results)`, `onError(error)`, `accept`, `multiple`, `className`, `disabled` and `children` (a node or a function of `{ state, progress, file }`). With `multiple` the files upload one after another and `onComplete` fires once with every result. The button is unstyled and exposes `data-state` and `aria-busy`. A result is `{ upload_id, file_slug, status, url, width, height, server_data? }`. When processing takes longer than the router's 30-second poll, `status` is still `processing` and `url` is `null`; the file becomes ready shortly after. `` renders an `` for an unsigned delivery URL; the chain uses the [delivery URL grammar](https://docs.img2.site/guides/delivery-urls). Protected projects need a server-signed URL instead (`img2site.signedUrl()`). app/profile/avatar-upload.tsx ``` // app/profile/avatar-upload.tsx "use client"; import { useState } from "react"; import { Img2siteImage, UploadButton } from "@img2site/sdk-react"; export function AvatarUpload() { const [slug, setSlug] = useState(null); return (
setSlug(results[0]?.file_slug ?? null)} onError={(error) => console.error(error.code, error.message)} /> {slug && ( )}
); } ``` ## How the upload works Upload steps and where the bytes go | Step | Who | What happens | | --- | --- | --- | | prepare | Browser → your handler | The handler validates the route, type and size, runs `middleware`, creates an upload session with your API key and returns the session plus an **upload token**. | | PUT | Browser → storage | The browser sends the bytes to the presigned storage URL of the session; your server never relays them. Only in `proxy` mode (or the proxy fallback) do they go through the img2site API's upload proxy. | | complete | Browser → your handler | The handler verifies the token, completes the session, polls it for up to 30 seconds and runs `onUploadComplete` once the file is `ready`. | | publish | img2site | The file is sanitized, stored and published on your delivery host. A `upload.ready` webhook goes out if the project has a callback URL. | ### Upload tokens The token is an HMAC keyed with a hash of your API key over the upload id, the session expiry and a state segment carrying the route name and your middleware metadata. The complete call needs no API key, and the browser cannot change the route, the metadata or the upload id. The state is base64url JSON, readable but tamper-proof, so do not put secrets in middleware metadata. Tokens expire with the session and stop working when you rotate the API key. ### Multipart above 16 MiB Files up to 16 MiB are one `PUT`. Larger files use S3 multipart: 8 MiB parts by default (the `partSize` option, at least 5 MiB; the session's part size wins), three parts at a time (`concurrency`), each retried up to three times on 5xx, 429 or network errors (`maxRetries`). Progress advances as each part is acknowledged. The storage bucket must expose the `ETag` header to the browser; otherwise the upload fails with `etag_missing`. ### Pause, resume, abort `pause()` stops in-flight part uploads and `resume()` sends only the parts that were not acknowledged. When a `PUT` or complete fails transiently on a live session (a network error, 408, 429 or 5xx), the error carries `error.resume()`, which continues the same session and re-sends only the missing parts. Permanent refusals (other 4xx, such as `422 unsupported_media_type`) have no `resume()`: fix the file and upload again. A multipart session lives 12 hours, so it also survives a page reload or a device that was off for hours: store the handle `onSession` gives you (it is a credential for that upload: keep it per user and clear it on logout), then call `upload(file, { resume: handle })` with the same file to send only the missing parts. Expired part URLs are re-signed automatically. `abort()` ends in an error with code `aborted` and also cancels the session on the server, so it cannot be resumed. `detach()` stops uploads in the page only (code `detached`) and keeps the session resumable. Unmounting a component that uses `useUpload` detaches an upload started with `onSession` or `resume`, and aborts any other, since nothing could resume it. ### Proxy fallback Some networks block direct uploads to the storage host. When you leave `mode` unset and a single-`PUT` upload fails once with a network or CORS error, the client prepares a fresh session in `proxy` mode and sends the bytes to that session's proxy `put_url` instead. Pass `mode: "proxy"` to `uploader.upload()` to use the proxy from the start. Multipart uploads are always direct. ## Custom UI with useUpload `useUpload({ endpoint, route? })` returns `upload`, `progress` (`{ sent, total }` or `null`), `state` (`idle`, `uploading`, `paused`, `done`, `error`), `result`, `error`, `pause`, `resume`, `abort` and `reset`. It also accepts the uploader options (`partSize`, `concurrency`, `headers`, `maxRetries`, `retryBaseDelayMs`, `fetch`). useUpload ``` "use client"; import { useUpload } from "@img2site/sdk-react"; export function CustomUploader() { const { upload, state, progress, result, error, pause, resume, abort, reset } = useUpload({ endpoint: "/api/img2site", route: "avatar", }); return (
{ const file = e.currentTarget.files?.[0]; if (file) upload(file).catch(() => {}); // the error is also in `error` }} /> {progress && } {state === "uploading" && } {(state === "paused" || (state === "error" && error?.resume)) && } {(state === "uploading" || state === "paused") && } {state === "done" &&

Uploaded {result?.file_slug}

} {state === "error" &&

{error?.code}: {error?.message}

}
); } ``` Without React (@img2site/sdk-client) ``` import { createUploader, imageUrl, Img2siteUploadError } from "@img2site/sdk-client"; const uploader = createUploader({ endpoint: "/api/img2site" }); async function send(file: File) { try { const result = await uploader.upload(file, { route: "avatar", onProgress: ({ loaded, total }) => console.log(`${Math.round((loaded / total) * 100)}%`), }); return imageUrl({ host: "my-project.i2scd.net", slug: result.file_slug, chain: "resize/256x256/smart" }); } catch (error) { // A failed PUT or complete of a live session can continue where it stopped. if (error instanceof Img2siteUploadError && error.resume) return error.resume(); throw error; } } ``` ## Without React `createUploader({ endpoint })` returns `upload(file, options)`, `pause()`, `resume()`, `abort()` and `detach()`. Per-upload options are `route`, `slug`, `input`, `onProgress` (`{ loaded, total }`), `signal`, `mode`, `onSession` (the multipart resume handle) and `resume` (a stored handle to continue). Use the `headers` option to send your app's own auth to your handler; it is never sent to storage. `imageUrl({ host, slug, chain?, kind? })` builds `https://{host}/{chain}/f/{slug}`; `kind` is `derived` (with a chain), `originals` (default without one) or `raw`. ## Responsive images `srcSetPresets` builds `srcSet` from your project's resolution presets as `preset:{name}` chains; pair it with `sizes`. `presetSrcSet(host, slug, presets)` returns the same string for your own markup. srcSetPresets ``` // src = https://my-project.i2scd.net/originals/f/hero // srcSet = …/preset:small/f/hero 480w, …/preset:large/f/hero 1600w ``` ## When something fails Errors returned by the upload handler | Status | Code | Meaning | | --- | --- | --- | | 400 | `unknown_route` | The route name is not in the router (or the router has several routes and none was given). | | 400 | `unsupported_content_type` | The route has no constraint for this type; details.accepted lists the configured keys. | | 413 | `file_too_large` | Larger than the route's maxSize; details.max\_bytes. | | 400 | `invalid_input` | The route's .input(schema) rejected the client input. | | 401, 403… | your code | UploadRejectedError thrown from middleware (default 403 forbidden). | | 500 | `middleware_failed` | Middleware threw anything else; the handler's onError option receives it. | | 401 | `invalid_token` | The complete, parts or abort call carried a forged or malformed upload token. | | 410 | `upload_expired` | The upload token (and its session) expired: start a new upload. Also the API's answer for an expired session. | | 422 | the session's error\_code | Processing failed or the session expired (for example unsupported\_media\_type or image\_too\_large). | | 500 | `on_upload_complete_failed` | The file is ready but onUploadComplete threw. | | 502 | api code | The img2site API was unreachable or answered 5xx. Refusals such as quota or suspension keep their own status. | In the browser these arrive as `Img2siteUploadError` with `stage` (`prepare`, `put`, `complete`), `status`, `code` and `details`. The client adds `network_error`, `aborted`, `detached`, `etag_missing`, `file_mismatch` (a resumed upload got a file of another size) and `invalid_response`. Calls from your server through `createClient` throw `Img2siteError` (`status`, `code`, `details`, `requestId`). ### Next steps [Verify webhooks](https://docs.img2.site/webhooks), learn the [delivery URL grammar](https://docs.img2.site/guides/delivery-urls), or browse the [REST reference](https://docs.img2.site/api). ### Server-side REST client The same `img2site` client covers `uploads`, `files` (`list`, `get`, `delete`), `signedUrl()`, `recipes`, `presets` and `usage.get()`. GET requests are retried with backoff; writes are not. --- # Getting started Source: https://docs.img2.site/getting-started Getting started # Publish one image, then connect your app. Start in the dashboard to see the whole path from upload to delivery URL without code. Then create an API key and let your app do the same. ## Try your first image in the dashboard No code, API key, or DNS setup is needed. Your workspace owns your projects; each project is a separate image library and delivery address for a website or app. 1. [Sign in and create a project](https://dashboard.img2.site/projects). Choose a short address such as `my-store`; its delivery host is `my-store.i2scd.net`. 2. On the Files page, choose an image and a file name such as `homepage-hero`, then upload it. 3. Wait for processing to finish. Open the published image's details and copy its delivery URL into your site or app. New projects deliver publicly: anyone with a delivery URL can view the image. Switch a project to protected delivery when every URL must be signed (see [signing](https://docs.img2.site/signing)). Step 1 ## Create an API key In the dashboard, open your project's API keys and create one. The full key (`i2s_live_…`) is shown once; store it in a server-side environment variable. A key without scopes has full access to its project; an upload router needs `files:write` and `files:read`. Step 2 ## Upload from your app With JavaScript, follow the [quickstart](https://docs.img2.site/quickstart): an upload router on your server and `` in the browser. From any other language, create an upload session with `POST /v1/projects/{slug}/uploads` and send the bytes where it says (see the [upload flow](https://docs.img2.site/guides/upload-flow)). Step 3 ## Wait for ready The upload is sanitized and published in the background. The session's `status` ends at `ready` (or `failed` with an `error_code`). The upload router waits for you; a [webhook](https://docs.img2.site/webhooks) tells your server even when the browser has gone away. Step 4 ## Build delivery URLs A published file is served at `https://{project}.i2scd.net/originals/f/{file_slug}`. Put a transform chain in place of `originals`, for example `resize/800x/format/webp`. The [delivery guide](https://docs.img2.site/guides/delivery-urls) lists every operation. ## Building with an AI assistant? Give your assistant [the documentation index](https://docs.img2.site/llms.txt) or [the full documentation as Markdown](https://docs.img2.site/llms-full.txt). Use placeholders for credentials, never real API keys or signed upload URLs. ``` Read https://docs.img2.site/llms.txt and the relevant linked guides. Help me add image uploads to my app with img2site. Ask which framework I use, prefer the @img2site SDKs for JavaScript, keep the API key on the server, and use environment-variable placeholders for secrets. Do not assume a feature is available unless the docs say so. ``` ### Project setup Delivery access, presets, allowed chains, watermarks, callbacks and suspension. [Read project setup](https://docs.img2.site/project-setup) ### Authentication API keys, scopes, upload tokens and which credential each call needs. [Choose a credential](https://docs.img2.site/auth) ### Troubleshooting If a call fails, the troubleshooting page maps symptoms to fixes. [Open troubleshooting](https://docs.img2.site/troubleshooting) --- # Project setup Source: https://docs.img2.site/project-setup Project setup # Everything a project controls. A project owns its files, API keys, signing keys, watermark presets, recipes and a delivery host. Projects belong to your organization and are managed from the dashboard (`PATCH /v1/projects/{slug}` with a dashboard session). Project API keys can read and upload files, sign URLs and manage presets and recipes, but cannot change project settings. ## Slug and delivery host `project_slug` is a DNS label of up to 63 characters and addresses the project in every API path. The project is served on `default_delivery_host`, which is `{project_slug}.i2scd.net`; no DNS setup is needed. Custom domains are set up by img2site staff and appear in `custom_domains`. `project_id` and `organization_id` are opaque identifiers; webhook payloads use them. Changing settings ``` PATCH https://api.img2.site/v1/projects/my-store Authorization: Bearer Content-Type: application/json { "delivery_access": "public", "resolution_presets": { "small": "resize/480x/format/webp", "large": "resize/1600x/format/webp" }, "allowed_transform_chains": ["preset:small", "preset:large", "resize/256x256/smart"], "raw_download_enabled": false, "callback_url": "https://www.my-store.example/webhooks/img2site" } ``` ## Settings Project settings changed with PATCH /v1/projects/{slug} | Field | What it does | | --- | --- | | `name` | Display name. | | `delivery_access` | `public` (default): anyone with a URL can fetch it. `protected`: every delivery URL must be signed; unsigned requests answer `403 signature_required`. See [signing](https://docs.img2.site/signing). | | `resolution_presets` | Up to 32 named chains, used in URLs as `preset:{name}` (one per URL). Names match `^[a-z0-9][a-z0-9._-]{0,63}$`. | | `allowed_transform_chains` | Up to 64 chains. When set, unsigned derived URLs must use one of them (`403 transform_chain_not_allowed` otherwise). Originals are exempt. On a `public` project, minting a signed URL with a chain outside the list fails with `400 transform_chain_not_allowed`; `protected` projects do not use the list. | | `default_watermark_preset_id` | A watermark preset applied to every derived image. See [delivery URLs](https://docs.img2.site/guides/delivery-urls) for how explicit presets interact with it. | | `raw_download_enabled` | Enables `/download/raw/f/{slug}`, which returns the sanitized image with its saved metadata restored as an attachment. Off by default (`404`). | | `callback_url` | An https URL that receives [webhooks](https://docs.img2.site/webhooks). Setting it the first time, or sending `rotate_callback_secret: true`, returns `callback_secret` once. | | `status` | `active` or `suspended`. `DELETE /v1/projects/{slug}` also suspends; there is no hard delete. | Unknown fields are refused with `400`. Changes to delivery access, status, presets, allowed chains, the default watermark or raw downloads purge the project's cached images, so existing URLs pick up the change promptly. ## Suspension A suspended project answers `403 project_suspended` on delivery and refuses uploads; its API keys stop authenticating. `suspended_by` says who suspended it (`owner` or `staff`). A staff suspension can only be lifted by staff (`403 project_suspended_by_staff`). ## Storage limit `storage_hard_limit_bytes` comes from your plan (`storage_limit_source: "plan"`) or is set by staff (`"staff"`). An upload that would exceed it is refused with `403 storage_quota_exceeded`. `file_count` and `storage_bytes` show current use. ## API keys Created, listed, rotated and revoked in the dashboard (dashboard session only). Up to 100 active keys per project. The full key is shown once at creation; lists show `prefix`, `last_four`, `scopes`, `status` and `last_used_at`. Rotation issues a new key and revokes the old one in one step. See [authentication](https://docs.img2.site/auth) for scopes. ## Signing keys Each project has one active signing key (`active_signing_kid`) used by `POST /v1/projects/{slug}/signed-url`. Rotating it keeps the previous key valid for 24 hours so issued URLs keep working. See [signing](https://docs.img2.site/signing). ## Watermark presets and recipes A watermark preset is `{ name, file_slug, x_offset, y_offset, opacity }`, where `file_slug` names a ready image in the same project; upload the watermark image first. Recipes are named transform chains you save for your own tooling (`/v1/projects/{slug}/recipes`). --- # Workflow guides Source: https://docs.img2.site/guides Flow guides # Three flows: upload, delivery, and usage. Each guide describes one flow over the REST API, what your app sends, what comes back and when to wait. With JavaScript, the [SDKs](https://docs.img2.site/quickstart) handle the upload flow for you. ## 1\. Upload 1. Your server creates an upload session with the file's type and size. 2. The bytes go to storage: one PUT or S3 multipart parts straight to the bucket, or through the API's upload proxy. 3. Complete the session (or let img2site notice the object) and wait for ready. [Read the upload-flow guide](https://docs.img2.site/guides/upload-flow) ## 2\. Delivery 1. A ready file is served on your project's delivery host. 2. A transform chain in the path picks the size, crop, format, quality and watermark. 3. Protected projects serve only signed URLs minted by your server. [Read the delivery-URLs guide](https://docs.img2.site/guides/delivery-urls) ## 3\. Usage and billing 1. Each successful delivery is metered by bytes served. 2. Closed hours are rolled up hourly; credits are charged once a day. 3. Delivery stops with 403 quota\_exceeded when the credit balance is used up. [Read the usage-and-billing guide](https://docs.img2.site/guides/usage-and-billing) ### Webhooks Signed notifications for ready uploads, moderation and quota changes. [Read about webhooks](https://docs.img2.site/webhooks) ### REST reference Every operation with its parameters, scopes and responses. [Open the reference](https://docs.img2.site/api) --- # Upload flow Source: https://docs.img2.site/guides/upload-flow Guide · Upload flow # Create a session, send the bytes, wait for ready. An upload session tells you where to put one file. The bytes go straight to storage, or through the API's upload proxy when you ask for proxy mode. img2site then sanitizes the image and publishes it under the session's `file_slug`. With JavaScript, the [upload router and browser SDK](https://docs.img2.site/quickstart) run this whole flow for you; this page is the same flow over plain HTTP. ## 1\. Create the session `POST /v1/projects/{slug}/uploads` with an API key that has `files:write`. The body takes `content_type` (`image/jpeg`, `image/png`, `image/webp`, `image/avif`, `image/gif`, `image/tiff`, `image/bmp` or `image/svg+xml`), `size_bytes` (the exact size), and optionally `file_slug`, `sha256` (hex, checked after upload), `mode` (`direct` or `proxy`) and `parts`. `image/svg+xml` is accepted only when SVG uploads are enabled on the deployment; otherwise it answers `400 unsupported_content_type`. An SVG may be at most 2 MB: a larger `size_bytes` answers `413 file_too_large` at create. `file_slug` is lowercased and must match `^[a-z0-9][a-z0-9._-]{0,127}$`; without one the upload id is used. Uploading to an existing slug replaces that file and increases its `revision`. Sessions expire after 15 minutes. Create ``` curl -X POST https://api.img2.site/v1/projects/my-store/uploads \ -H "X-Api-Key: $IMG2SITE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_slug": "hero", "content_type": "image/jpeg", "size_bytes": 482133 }' 201 Created { "upload_id": "upl_…", "project_slug": "my-store", "file_slug": "hero", "status": "created", "mode": "direct", "content_type": "image/jpeg", "size_bytes": 482133, "error_code": null, "expires_at": "2026-09-24T12:15:00.000Z", "direct": { "put_url": "https://…presigned storage URL…", "headers": { "content-type": "image/jpeg", "content-length": "482133" }, "expires_at": "2026-09-24T12:15:00.000Z" }, … } ``` Send and complete ``` # Send the bytes to direct.put_url with exactly the returned headers. curl -X PUT "$PUT_URL" \ -H "Content-Type: image/jpeg" \ --data-binary @hero.jpg # Then complete the session (optional for a single PUT, makes it immediate). curl -X POST https://api.img2.site/v1/projects/my-store/uploads/$UPLOAD_ID/complete \ -H "X-Api-Key: $IMG2SITE_API_KEY" ``` ## 2\. Send the bytes The response carries exactly one of three targets: - `direct`: `PUT` the file to `put_url` with the returned `headers`. The URL signs the content type and length, so send exactly those. - `multipart` (when `parts` > 1): `PUT` each part to its URL, then complete with the part ETags. Parts are at least 5 MiB except the last. - `proxy` (when `mode` is `proxy`): `PUT` the file to the `put_url` the session returned (its host depends on the deployment; do not hard-code it) with `Content-Length` equal to `size_bytes` and the session's `Content-Type`. Use it when a network blocks the storage host. It completes the session itself (`204`). `POST …/uploads/{id}/complete` is required for multipart, with every part of the session (`part_number` 1 to N once each, with its ETag); a partial list answers `400 invalid_parts` and the session stays completable. It is optional otherwise: a background job completes sessions whose object has arrived within a few minutes. Calling it checks the object is there with the declared size and makes completion immediate. Completion is idempotent. ## 3\. Wait for ready Poll `GET /v1/projects/{slug}/uploads/{id}` every second or two, or subscribe to the `upload.ready` [webhook](https://docs.img2.site/webhooks). When the session is `ready`, the file is published: read it with `GET /v1/projects/{slug}/files/{fileSlug}` and build delivery URLs from its slug (see [delivery URLs](https://docs.img2.site/guides/delivery-urls)). Publishing sanitizes the image: it is re-encoded in the same format (BMP and TIFF become PNG), orientation is applied and embedded metadata is removed. Animated images and HEIC are refused. Size limits: JPEG and PNG images (8 bits per channel) up to 128 megapixels; GIF, TIFF, BMP, lossy WebP without transparency and 16-bit images up to 50 megapixels; lossless WebP up to about 49 megapixels (about 32 with transparency); lossy WebP with transparency up to about 38 megapixels; AVIF up to about 28 megapixels. Larger images are refused as too costly to process. SVGs are sanitized: scripts, event handlers, external references, animation and editor metadata are removed. SVGs with embedded bitmap images or a DOCTYPE are refused, and so are very complex ones (too many elements, layers, text or styles). The sanitized SVG is stored and served as vector on originals; transforms rasterize it on a grid whose long edge is at least 2048 px (up to 2048 × 2048 in area, default output PNG), and the file's `width`/`height` report that grid. Poll and read the file ``` curl https://api.img2.site/v1/projects/my-store/uploads/$UPLOAD_ID \ -H "X-Api-Key: $IMG2SITE_API_KEY" { "upload_id": "upl_…", "file_slug": "hero", "status": "ready", "ready_at": "…", "error_code": null, … } # The published file: curl https://api.img2.site/v1/projects/my-store/files/hero \ -H "X-Api-Key: $IMG2SITE_API_KEY" { "file_slug": "hero", "status": "ready", "revision": 1, "width": 2400, "height": 1600, "url": "https://my-store.i2scd.net/originals/f/hero", … } ``` ## Session statuses Upload session statuses | Status | Meaning | Next action | | --- | --- | --- | | `created` | The session exists; img2site is waiting for the bytes. | Send the bytes before expires\_at, then complete. | | `uploaded` | The bytes arrived and the file is queued for processing. | Keep polling. | | `processing` | The image is being sanitized and published. | Keep polling; this usually takes seconds. | | `ready` | The file is published and served on the delivery host. | Use the file slug in delivery URLs. | | `failed` | Processing refused the file; `error_code` says why. | Fix the file and upload again with a new session. | | `expired` | No bytes arrived before expires\_at. | Create a new session. | Multipart ``` // "parts": 3 in the create body selects S3 multipart (direct mode only) "multipart": { "s3_upload_id": "…", "part_size": 8388608, "parts": [ { "part_number": 1, "url": "…" }, { "part_number": 2, "url": "…" }, … ], "complete_url": "/v1/projects/my-store/uploads/upl_…/complete" } // PUT each part to its url, keep each response's ETag, then: POST /v1/projects/my-store/uploads/upl_…/complete { "parts": [ { "part_number": 1, "etag": "\"…\"" }, { "part_number": 2, "etag": "\"…\"" } ] } ``` ## Processing errors error\_code values of a failed upload session | error\_code | Cause | | --- | --- | | `unsupported_media_type` | Not a supported image format (for example HEIC, compressed SVG, or SVG with embedded images or a DOCTYPE). | | `animated_not_supported` | The image has more than one frame. | | `image_too_large` | Above the size limit for its format (see above), or too costly to process: for example a palette PNG that becomes too large once re-encoded. | | `processing_timeout` | Processing a very large image took too long three times; upload again later, or a smaller image. | | `decode_failed` | The file is corrupt or truncated. | | `ingest_size_mismatch` | The stored object is not size\_bytes long. | | `sha256_mismatch` | The bytes do not match the declared sha256. | | `file_too_large` | Above the upload size limit. | | `ingest_object_missing` | The bytes never arrived. | | `processing_abandoned` | Processing failed repeatedly; upload again. | | `session_expired` | No bytes arrived before expires\_at (status expired); create a new session. | ## Limits and refusals - An upload that would exceed the project's storage limit is refused at create with `403 storage_quota_exceeded`. - A suspended project refuses uploads with `403 project_suspended`. - A `size_bytes` above the upload limit is refused at create with `413 file_too_large` (`details.max_bytes`); for `image/svg+xml` the limit is 2 MB. - Completing a session whose object is missing answers `409 ingest_object_missing`; a wrong size answers `409 ingest_size_mismatch`. - A multipart complete with missing, repeated or not-yet-uploaded parts (or a part other than the last under 5 MiB) answers `400 invalid_parts` and leaves the upload completable; send the full list. - The proxy `PUT` answers `401 invalid_signature` for an altered URL and `401 signature_expired` once the session's URL expired. It accepts cross-origin browser requests (CORS for any origin; the signed URL is the credential). - A file blocked by moderation stays blocked when its slug is uploaded again. - The full list of codes is on the [errors](https://docs.img2.site/errors) page. --- # Delivery URLs Source: https://docs.img2.site/guides/delivery-urls Guide · Delivery URLs # One URL shape, a small transform grammar, and an optional signature. Every ready file is served on the project's delivery host. The path is a transform chain, the `/f/` marker and the file slug. The first request for a chain renders it; after that it is served from cache. In JavaScript, `imageUrl()` from `@img2site/sdk-client` and `` build these URLs for you. ## URL shape ``` https://{project}.i2scd.net/{transform_chain}/f/{file_slug} https://{project}.i2scd.net/originals/f/{file_slug} https://{project}.i2scd.net/download/raw/f/{file_slug} [?exp=…&kid=…&sig=… signed URLs only] ``` Operations are lowercase `name/value` segments separated by slashes, in any order; each may appear once. The `/f/` segment is required. Only `GET` and `HEAD` are served; `OPTIONS` answers CORS for any origin. Examples ``` # The sanitized image at its own size https://my-store.i2scd.net/originals/f/hero # 1024 px wide, height keeps the aspect ratio https://my-store.i2scd.net/resize/1024x/f/hero # 800x600 with attention-based cropping, WebP at quality 80 https://my-store.i2scd.net/resize/800x600/smart/format/webp/quality/80/f/hero # Crop a box first, then resize https://my-store.i2scd.net/crop/100,50,1200,800/resize/600x/f/hero # A resolution preset defined on the project, with a named watermark preset https://my-store.i2scd.net/preset:large/watermark:wm_brand/f/hero ``` ## Transform operations Transform operations of the delivery URL grammar | Operation | Syntax | Notes | | --- | --- | --- | | `resize` | `resize/{w}x{h}` | 1 to 8192 px per side, within the output limits below. Leave one side empty (1024x, x600) to keep the aspect ratio. Upscaling is allowed up to 16 times the source area (any source may go up to 2048x2048). | | `fit mode` | `resize/{w}x{h}/{mode} or resize/{w}x{h}/fit/{mode}` | fill (default; crop is an alias) covers the box and crops the centre, smart covers it and crops around the most interesting region, fit fits inside the box. | | `crop` | `crop/{x},{y},{w},{h}` | A pixel box taken from the source before resizing. | | `format` | `format/{webp|avif|jpeg|png}` | Output format (jpg is accepted for jpeg). Default: the stored format (PNG for an SVG, whose crop and resize coordinates are in its raster grid: the file's width and height). | | `quality` | `quality/{1-100}` | Encoder quality for jpeg, webp and avif; mapped to compression level for png. | | `watermark` | `watermark or watermark:{preset}` | Composite a watermark preset. See below for how it combines with the project default. | | `preset` | `preset:{name}` | Expands to one of the project's resolution presets. At most one per URL. | A chain that does not parse, names an unknown preset, or repeats an operation answers `404 not_found`. Output never carries embedded metadata and is at most 8192 px on each side. Output limits: a rendered image is at most 16.7 megapixels (4096x4096) in webp, jpeg and png, and at most 12 megapixels (for example 4000x3000) in avif; a larger request is scaled down to fit, keeping its aspect ratio. Upscaling is limited to 16 times the source area (or 2048x2048, whichever is larger): a chain that enlarges more answers `404 not_found` without rendering. Very large avif and webp sources get smaller renders, and avif sources above about 15 megapixels, like lossless webp sources above about 22.5 to 25 megapixels, cannot be transformed at all (`404 not_found`). For the full-size file, use `/originals/f/{slug}`. ## Watermarks When the project has a default watermark preset, it is applied to **every** derived image, with or without a `watermark` operation. `watermark:{preset}` uses that preset instead; a preset id the project does not have falls back to the default, so a made-up name cannot remove it. Originals and raw downloads are never watermarked. Every preset of a project can be selected on public URLs, so a faint or zero-opacity preset weakens the default. Restrict public chains with `allowed_transform_chains` if that matters (see [project setup](https://docs.img2.site/project-setup)). ## Originals and raw downloads `/originals/f/{slug}` serves the sanitized image as stored, without transforms or metadata. `/download/raw/f/{slug}` serves the same image with its saved EXIF, ICC and XMP metadata restored, as an attachment and never cached. It works only when the project enables `raw_download_enabled`; otherwise it answers `404`. It is not the byte-identical upload. ## Public and protected delivery On a `public` project anyone with a URL can fetch it. On a `protected` project every URL must carry `exp`, `kid` and `sig`, minted by your server with `POST /v1/projects/{slug}/signed-url` or `img2site.signedUrl()`; unsigned requests answer `403 signature_required`. See [signing](https://docs.img2.site/signing). ## No query strings An unsigned URL with any query string answers `404 not_found`, and a signed URL may carry only `exp`, `kid` and `sig`. Do not add cache-busting parameters: re-uploading a slug changes what its URLs serve on its own. ## Caching - Public images: `Cache-Control: public, max-age=300, s-maxage=31536000`. Browsers revalidate after five minutes; the CDN keeps images up to a year and is purged whenever something changes what a URL serves (re-upload, delete, block, project settings, watermark presets). Purges are queued, not instant, and delivery servers cache file and project lookups for up to 30 seconds, so a deleted, blocked or re-uploaded file can keep serving briefly. - Signed URLs are cached by the CDN until they expire; browsers revalidate them. - Raw downloads and errors are never cached. - Responses carry `ETag` and `X-Img2site-Revision` (the file's revision), so you can tell which upload a response came from. ## Delivery errors Delivery error responses, in the order they are checked | Status | Code | Cause | | --- | --- | --- | | 404 | `not_found` | Malformed path, unexpected query string, or unknown host. | | 403 | `project_suspended` | The project is suspended. | | 403 | `quota_exceeded` | The organization has no credits left. See [usage and billing](https://docs.img2.site/guides/usage-and-billing). | | 404 | `not_found` | The file does not exist or was deleted. | | 403 | `file_blocked` | Moderation blocked the file. Applies to every path, including originals and raw downloads. | | 403 | `invalid_signature`, `signature_expired`, `unknown_kid` | The signature does not verify, has expired, or names a key that no longer exists. | | 403 | `signature_required` | A protected project received an unsigned URL. | | 403 | `transform_chain_not_allowed` | An unsigned derived URL uses a chain outside allowed\_transform\_chains. | | 404 | `not_found` | Raw downloads are disabled, or the chain fails the grammar or names an unknown preset. | | 415 | `raw_unavailable` | The file's format cannot take its metadata back for a raw download. | | 503 | `busy` | Rendering is saturated; retry after the Retry-After seconds. | | 504 | `render_timeout` | The render took too long; retry. | Refused requests are never billed. --- # Usage and billing Source: https://docs.img2.site/guides/usage-and-billing Guide · Usage and billing # Deliveries use credits; credits are charged once a day. Usage is metered from what your audience actually receives. Every successful delivery is recorded, closed hours are rolled up at five past each hour, and the organization's credit balance is charged once a day. Your plan adds credits every month. ## What counts - Each successful delivery (2xx or 304) of an original, a derived image or a raw download is one request. - Refused requests (status 400 and above, including `quota_exceeded` and `file_blocked`) are never usage. - Responses the CDN serves from its cache without reaching img2site are not metered. - Uploads and stored bytes are not metered as requests; storage is limited by your plan. ## Credits per delivery A delivery costs one credit per MiB served, rounded up, with a minimum of one. Raw downloads cost twice as much and watermarked images 1.2 times as much. Resizing and format changes add no surcharge. ``` credits = max(1, ceil(bytes × (raw ? 2 : 1) × (watermark ? 1.2 : 1) / 1 MiB)) ``` The daily charge for a project is the sum of that day's delivery credits times the project's pricing multiplier (1 unless img2site has agreed otherwise), rounded up: ``` charged_credits = ceil(Σ credits of the day × pricing_multiplier) ``` ## When totals become final Metering schedule | When | What happens | | --- | --- | | Five past each hour | Every closed hour of the last day is rolled up into hourly totals. A late event updates its hour on the next run. | | Two hours after the hour | The hour is final; `reconciled_through` is the start of the newest final hour. | | 00:20 UTC daily | Each day's credits are charged to the organization's balance. The last three days are recomputed on every run, so late events raise a charge and corrections refund it; nothing is charged twice. | ## Reading usage `GET /v1/projects/{slug}/usage?from&to` with a key that has `files:read` (or `img2site.usage.get()`). `from` and `to` are ISO dates or timestamps; the default is the last 30 days and ranges over 90 days are shortened (`range_capped: true`). - `storage` is the project's current file count and bytes. - `hourly` and `daily` hold requests, bytes and credits (before the pricing multiplier); `charged_credits` is what was charged for the day. - `transforms` is reserved and is always `0` today; transforms are not counted separately. - `credit_balance` is the organization's balance. Usage response ``` curl "https://api.img2.site/v1/projects/my-store/usage?from=2026-09-01&to=2026-09-24" \ -H "X-Api-Key: $IMG2SITE_API_KEY" 200 OK { "project_slug": "my-store", "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-24T00:00:00.000Z", "range_capped": false, "storage": { "file_count": 1284, "storage_bytes": 734003200 }, "hourly": [ { "window_start": "2026-09-23T22:00:00.000Z", "requests": 281, "bytes": 11240321, "transforms": 0, "credits": 342, "computed_at": "2026-09-23T23:05:12.000Z" }, … ], "daily": [ { "day": "2026-09-22", "requests": 6120, "bytes": 245118904, "transforms": 0, "credits": 7410, "charged_credits": 7410, "hours_closed": 24, "finalized_at": "2026-09-23T00:20:04.000Z" }, … ], "credit_balance": 184220, "reconciled_through": "2026-09-23T21:00:00.000Z" } ``` ## When the balance reaches zero After the daily charge takes the balance to zero or below, every project of the organization answers `403 quota_exceeded` on delivery, and projects with a callback URL receive a `quota.exceeded` [webhook](https://docs.img2.site/webhooks). When credits are added (a paid billing period), delivery resumes and `quota.restored` is sent. ## Plans and invoices Subscriptions are managed in the dashboard's Usage & billing page. Checkout and the billing portal are run by Creem, our merchant of record, which calculates applicable taxes at checkout and emails your receipts. Subscriptions renew monthly; **Cancel subscription** on the same page ends the plan at the end of the paid period, with no further charges. Each paid billing period adds the plan's monthly credits. The plan also sets each project's storage limit; an upload above it answers `403 storage_quota_exceeded`. Automatic overages are not enabled. ## Common questions - **Why don't I see today's traffic yet?** Hours appear after the rollup at five past the next hour, and the balance changes once a day. - **Why are there fewer requests than page views?** Repeat views served from the CDN cache or the browser cache do not reach img2site and are not metered. - **Does uploading use credits?** No. Only deliveries do. --- # Webhooks Source: https://docs.img2.site/webhooks Guide · Webhooks # Hear about uploads, moderation and quota from img2site. When a project has a callback URL, img2site sends a signed `POST` for each event. Verify the signature over the raw body with `verifyWebhook`, answer 2xx within ten seconds, and treat the delivery id as your idempotency key. ## Set up a callback URL Set the project's `callback_url` in the dashboard project settings (or with `PATCH /v1/projects/{slug}` from a dashboard session). The URL must be `https` and resolve to a public address. The response that first sets a callback URL, or that sends `rotate_callback_secret: true`, carries the signing secret `callback_secret` (`whsec_…`) **once**. Afterwards the project only shows `callback_secret_set`. Store the secret as a server-side environment variable. Events are only generated for projects that have a callback URL when the event happens. Removing the URL stops new events; there is no backfill. A delivery ``` POST https://example.com/webhooks/img2site Content-Type: application/json User-Agent: img2site-webhooks/2 X-Img2site-Event: upload.ready X-Img2site-Delivery: upload.ready:upl_01J8Z… X-Img2site-Signature: t=1790000000,v1=5f2b…(64 hex) {"id":"upload.ready:upl_01J8Z…","event":"upload.ready","created_at":"2026-09-22T10:00:00.000Z", "data":{"upload_id":"upl_01J8Z…","project_id":"proj_…","file_id":"file_…","file_slug":"hero","sha256":"9c1f…","revision":1}} ``` ## Events Webhook events and their data fields | Event | When | data fields | | --- | --- | --- | | `upload.ready` | An upload was processed and the file is published (also for a same-slug re-upload, with a higher revision). | upload\_id, project\_id, file\_id, file\_slug, sha256, revision | | `file.blocked` | Moderation (the automatic scanner or staff) blocked the file; delivery answers 403 file\_blocked. | project\_id, file\_id, file\_slug, revision, reason | | `file.unblocked` | Staff unblocked the file; it is served again. | project\_id, file\_id, file\_slug, revision | | `quota.exceeded` | The organization's credit balance reached zero; delivery answers 403 quota\_exceeded. One event per project with a callback URL. | organization\_id, project\_id, day, credit\_balance, quota\_exceeded\_at | | `quota.restored` | Credits were added and delivery resumes. | organization\_id, project\_id, day, credit\_balance, quota\_exceeded\_at (null) | The body is always `{ id, event, created_at, data }`. `id` is stable across retries of the same event (it equals the `X-Img2site-Delivery` header), `created_at` is when the delivery was first recorded, and `data` holds the fields above. Bodies are at most 2 KB. Order is not guaranteed: compare `revision` before acting on an older file event. ## Headers Headers sent with every webhook delivery | Header | Value | | --- | --- | | `Content-Type` | application/json | | `User-Agent` | img2site-webhooks/2 | | `X-Img2site-Event` | The event name, for example upload.ready. | | `X-Img2site-Delivery` | The event id (same as the body's id), stable across retries. | | `X-Img2site-Signature` | `t=,v1=`. The same scheme as Stripe, so existing verifiers work. | ## Verify with verifyWebhook `verifyWebhook(rawBody, headers, callbackSecret, { toleranceSec })` from `@img2site/sdk-server` takes the raw body (`string` or `Uint8Array`), the headers (a `Headers` object or a Node header record) and the secret. It returns the parsed `WebhookEvent` or throws `WebhookSignatureError`. `toleranceSec` defaults to 300. Pass the body exactly as received. A body parsed and serialized again (for example by `express.json()`) no longer matches the signature. Next.js route handler ``` // app/api/img2site-webhook/route.ts (Next.js App Router, Node.js runtime) import { verifyWebhook, WebhookSignatureError, type WebhookEvent } from "@img2site/sdk-server"; export async function POST(request: Request) { const rawBody = await request.text(); // the exact bytes; never JSON.stringify(await request.json()) let event: WebhookEvent; try { event = verifyWebhook(rawBody, request.headers, process.env.IMG2SITE_CALLBACK_SECRET!, { toleranceSec: 300 }); } catch (error) { if (error instanceof WebhookSignatureError) return new Response(error.reason, { status: 400 }); throw error; } // Deliveries are at-least-once: the same id can arrive again. if (await alreadyHandled(event.id)) return new Response(null, { status: 204 }); switch (event.event) { case "upload.ready": await onFileReady(String(event.data.file_slug), Number(event.data.revision)); break; case "file.blocked": await hideFile(String(event.data.file_slug), String(event.data.reason)); break; } await markHandled(event.id); return new Response(null, { status: 204 }); } ``` Express ``` import express from "express"; import { verifyWebhook, WebhookSignatureError } from "@img2site/sdk-server"; const app = express(); // express.raw keeps req.body as the raw Buffer; express.json() would break the signature. app.post("/webhooks/img2site", express.raw({ type: "application/json" }), (req, res) => { try { const event = verifyWebhook(req.body, req.headers, process.env.IMG2SITE_CALLBACK_SECRET!); queue.push(event); // do the work after answering: a delivery must get a 2xx within 10 s res.sendStatus(204); } catch (error) { if (error instanceof WebhookSignatureError) res.status(400).send(error.reason); else throw error; } }); ``` ### Failure reasons WebhookSignatureError reasons | reason | Cause | | --- | --- | | `missing_header` | No X-Img2site-Signature header. | | `malformed_header` | The header is not t=,v1=. | | `timestamp_out_of_tolerance` | |now − t| is larger than toleranceSec. | | `signature_mismatch` | No v1 value matches: wrong secret, or the body changed. | | `invalid_body` | The signature matched but the body is not { id, event, created\_at, data } JSON. | ## Replay protection and tolerance `t` is the time of each attempt, and every retry is signed again with a fresh `t`, so a retry hours later still verifies. A captured request replayed after the tolerance window fails with `timestamp_out_of_tolerance`. Within the window, reject repeats by remembering the event `id`. Keep your server clock synchronized (NTP); the check compares your clock with ours in both directions. Several `v1=` values in one header are accepted if any matches. Other languages can verify with the formula below and a constant-time comparison. ``` signed_payload = t + "." + raw_body expected = hex(hmac_sha256(callback_secret, signed_payload)) valid = constant_time_equal(expected, v1) and abs(now - t) <= 300 ``` A tampered body and a stale timestamp ``` import { createHmac } from "node:crypto"; import { verifyWebhook, WebhookSignatureError } from "@img2site/sdk-server"; const secret = "whsec_test"; const body = JSON.stringify({ id: "upload.ready:upl_test", event: "upload.ready", created_at: new Date().toISOString(), data: { file_slug: "hero", revision: 1 }, }); const t = Math.floor(Date.now() / 1000); const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"); const headers = { "x-img2site-signature": `t=${t},v1=${v1}` }; verifyWebhook(body, headers, secret); // → { id, event: "upload.ready", created_at, data } try { verifyWebhook(body.replace('"hero"', '"admin"'), headers, secret); } catch (error) { console.log((error as WebhookSignatureError).reason); // "signature_mismatch" } try { // The same delivery replayed ten minutes later. verifyWebhook(body, headers, secret, { now: new Date((t + 600) * 1000) }); } catch (error) { console.log((error as WebhookSignatureError).reason); // "timestamp_out_of_tolerance" } ``` ## Retries and abandonment A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else is a failed attempt: other status codes (including 3xx, since redirects are not followed), timeouts, connection errors and callback URLs that resolve to a private or link-local address. Failed deliveries are retried with exponential backoff (30 s, 1 min, 2 min, 4 min and so on, capped at one hour between attempts; retries are picked up by a job that runs every minute). After 10 attempts, about three hours, the delivery is abandoned and not sent again. Because the same event can arrive more than once, make handlers idempotent and dedupe on `id`. Do slow work after answering. If you miss events, reconcile from the API (for example `GET /v1/projects/{slug}/files`); webhooks are a notification, not the source of truth. ### Uploads without webhooks The upload router's `onUploadComplete` runs when the browser completes an upload. Use webhooks as well when the result must be recorded even if the browser goes away. See the [quickstart](https://docs.img2.site/quickstart). ### Reference The project fields `callback_url`, `callback_secret_set` and `rotate_callback_secret` are in the [REST reference](https://docs.img2.site/api#tag-projects) under projects. --- # REST reference Source: https://docs.img2.site/api Reference · REST API # REST API reference. 85 operations in 20 groups, generated from the API's OpenAPI contract. The SDKs in the [quickstart](https://docs.img2.site/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 | 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). | ## Error envelope Every error response has this shape. Branch on `code`; `message` is for humans. Error envelope fields | 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 } } } ``` ## Shared error responses Shared error responses and their codes | 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 | 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) | | Unavailable | 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 | ## Groups - [probes](https://docs.img2.site/api#tag-probes) (2) - [internal](https://docs.img2.site/api#tag-internal) (1) - [identity](https://docs.img2.site/api#tag-identity) (3) - [projects](https://docs.img2.site/api#tag-projects) (5) - [keys](https://docs.img2.site/api#tag-keys) (5) - [signing](https://docs.img2.site/api#tag-signing) (3) - [presets](https://docs.img2.site/api#tag-presets) (5) - [recipes](https://docs.img2.site/api#tag-recipes) (5) - [files](https://docs.img2.site/api#tag-files) (6) - [libraries](https://docs.img2.site/api#tag-libraries) (5) - [library-sessions](https://docs.img2.site/api#tag-library-sessions) (7) - [uploads](https://docs.img2.site/api#tag-uploads) (7) - [usage](https://docs.img2.site/api#tag-usage) (1) - [audit](https://docs.img2.site/api#tag-audit) (1) - [billing](https://docs.img2.site/api#tag-billing) (7) - [public](https://docs.img2.site/api#tag-public) (1) - [status](https://docs.img2.site/api#tag-status) (1) - [staff](https://docs.img2.site/api#tag-staff) (13) - [checks](https://docs.img2.site/api#tag-checks) (4) - [incidents](https://docs.img2.site/api#tag-incidents) (3) ## 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 | Field | Type | Required | Description | | --- | --- | --- | --- | | `events` | `object[]` | yes | | **Responses:** `200` Counted (`StorageEventsResult`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `500` [Internal](https://docs.img2.site/api#response-internal) ## identity The signed-in dashboard user and their organization. ### `GET` `/v1/me` The session's user, organization, role and staff flag **Operation:** `getMe` · **Auth:** BrowserSession + WorkosUserId **Responses:** `200` The caller (`Me`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `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](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | | **Responses:** `200` Tenant caller: its own organization (`Organization`), `201` Staff caller: the new organization (`Organization`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/projects/{slug}` **Operation:** `getProject` · **Auth:** BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read) getProject parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The project (`Project`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `ProjectPatch`, required) updateProject request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `DELETE` `/v1/projects/{slug}` Suspends the project (suspended\_by owner). There is no hard delete. **Operation:** `suspendProject` · **Auth:** BrowserSession + WorkosUserId + RequestSignature suspendProject parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `204` Suspended (repeat calls too), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests) ### `GET` `/v1/projects/{slug}/keys` Every key of the project, newest first (bounded, next\_cursor null) **Operation:** `listApiKeys` · **Auth:** BrowserSession + WorkosUserId listApiKeys parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The keys (`ApiKeyList`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `ApiKeyCreate`, required) createApiKey request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `POST` `/v1/projects/{slug}/keys/{keyId}/revoke` **Operation:** `revokeApiKey` · **Auth:** BrowserSession + WorkosUserId + RequestSignature revokeApiKey parameters | 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](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `keyId` | path | yes | key\_… id | **Responses:** `200` The new key (`ApiKeyRotated`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The signing keys (`SigningKeyList`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` Rotated (`SigningKeyRotation`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `SignedUrlRequest`, required) createSignedUrl request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The presets (`WatermarkPresetList`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `WatermarkPresetInput`, required) createWatermarkPreset request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/projects/{slug}/watermark-presets/{id}` **Operation:** `getWatermarkPreset` · **Auth:** BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read) getWatermarkPreset parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | wm\_… id | **Responses:** `200` The preset (`WatermarkPreset`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | wm\_… id | **Body** (application/json, `WatermarkPresetInput`, required) saveWatermarkPreset request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | wm\_… id | **Responses:** `204` Deleted, `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The recipes (`RecipeList`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `RecipeInput`, required) createRecipe request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | | | `transform_chain` | `string` | yes | | | `schema_version` | `1` | no | | **Responses:** `201` Created (`Recipe`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/projects/{slug}/recipes/{id}` **Operation:** `getRecipe` · **Auth:** BrowserSession + WorkosUserId or ApiKey (recipes:read) or ApiKeyHeader (recipes:read) getRecipe parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | rcp\_… id | **Responses:** `200` The recipe (`Recipe`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | rcp\_… id | **Body** (application/json, `RecipeInput`, required) saveRecipe request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | yes | | | `transform_chain` | `string` | yes | | | `schema_version` | `1` | no | | **Responses:** `200` Replaced (`Recipe`), `201` Created (`Recipe`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | rcp\_… id | **Responses:** `204` Deleted, `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `fileSlug` | path | yes | | **Body** (application/json, `FilePatch`, required) updateFile request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `fileSlug` | path | yes | | **Responses:** `204` Deleted, `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `fileSlug` | path | yes | | **Body** (application/json, `ModerationNoteInput`, required) createModerationNote request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `note` | `string` | no | | | `body` | `string` | no | | **Responses:** `201` The note (`ModerationNote`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `FileMove`, required) moveFiles request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `LibraryCreate`, required) createLibrary request body fields | 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: / http://127.0.0.1:; no path, no wildcard | **Responses:** `201` The library (`Library`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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) updateLibrary request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `LibrarySessionCreate`, required) createLibrarySession request body fields | 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: / http://127.0.0.1: 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `409` [Conflict](https://docs.img2.site/api#response-conflict), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | upl\_… id | **Responses:** `200` The upload (`LibraryUpload`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | upl\_… id | **Responses:** `200` The upload (`LibraryUpload`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 , 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 | 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](https://docs.img2.site/api#response-badrequest), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `file` | path | yes | | **Responses:** `200` The asset, `404` [NotFound](https://docs.img2.site/api#response-notfound), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `UploadCreate`, required) createUpload request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/projects/{slug}/uploads/{id}` **Operation:** `getUpload` · **Auth:** BrowserSession + WorkosUserId or ApiKey (files:read) or ApiKeyHeader (files:read) getUpload parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | upl\_… id | **Responses:** `200` The session (`UploadSession`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | upl\_… id | **Responses:** `204` Aborted (or already expired), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | upl\_… id | **Body** (application/json, `UploadComplete`, optional) completeUpload request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `410` [Gone](https://docs.img2.site/api#response-gone), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | | `id` | path | yes | upl\_… id | **Body** (application/json, `UploadPartsRequest`, optional) refreshUploadParts request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `410` [Gone](https://docs.img2.site/api#response-gone), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `411` [LengthRequired](https://docs.img2.site/api#response-lengthrequired), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `415` [UnsupportedMediaType](https://docs.img2.site/api#response-unsupportedmediatype), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ## billing Checkout and billing portal (Creem, merchant of record), subscription cancellation, invoices and the payment webhooks. ### `GET` `/v1/billing/summary` **Operation:** `getBillingSummary` · **Auth:** BrowserSession + WorkosUserId **Responses:** `200` The caller's organization billing state (`BillingSummary`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | 1..100, default 10 | **Responses:** `200` Invoices (`InvoiceList`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Field | Type | Required | Description | | --- | --- | --- | --- | | `return_url` | `string` | no | | **Responses:** `200` The portal session (`PortalSession`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `500` [Internal](https://docs.img2.site/api#response-internal), `503` [Unavailable](https://docs.img2.site/api#response-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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `500` [Internal](https://docs.img2.site/api#response-internal), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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) createAccessRequest request body fields | 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](https://docs.img2.site/api#response-badrequest), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `organization_id` | path | yes | org\_… id | **Responses:** `200` The organization (`StaffOrganization`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `organization_id` | path | yes | org\_… id | **Body** (application/json, `StaffOrganizationPatch`, required) staffUpdateOrganization request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/projects` All organizations' projects, keyset **Operation:** `staffListProjects` · **Auth:** Staff + WorkosUserId staffListProjects parameters | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/projects/{slug}` One project (any organization); audited as staff.project.read **Operation:** `staffGetProject` · **Auth:** Staff + WorkosUserId staffGetProject parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Responses:** `200` The project (`StaffProject`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `slug` | path | yes | Project slug (DNS label) | **Body** (application/json, `StaffProjectPatch`, required) staffUpdateProject request body fields | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/projects/{slug}/files` **Operation:** `staffListProjectFiles` · **Auth:** Staff + WorkosUserId staffListProjectFiles parameters | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/projects/{slug}/uploads` **Operation:** `staffListProjectUploads` · **Auth:** Staff + WorkosUserId staffListProjectUploads parameters | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/projects/{slug}/usage` **Operation:** `staffGetProjectUsage` · **Auth:** Staff + WorkosUserId staffGetProjectUsage parameters | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `fileId` | path | yes | file\_… id | **Body** (application/json, `BlockRequest`, required) staffBlockFile request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | `string` | yes | | **Responses:** `200` The file (`ModerationResult`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `fileId` | path | yes | file\_… id | **Body** (application/json, `UnblockRequest`, optional) staffUnblockFile request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `note` | `string` | no | Stored on the check\_reviews row the unblock records | **Responses:** `200` The file (`ModerationResult`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/moderation-notes` **Operation:** `staffListModerationNotes` · **Auth:** Staff + WorkosUserId staffListModerationNotes parameters | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `project_slug` | query | no | | **Responses:** `200` Reconciliation state (`Reconciliation`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) ### `GET` `/v1/staff/checks/{id}` **Operation:** `staffGetCheck` · **Auth:** Staff + WorkosUserId staffGetCheck parameters | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | chk\_… id | **Responses:** `200` The check and its reviews (`CheckDetail`), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `422` [Unprocessable](https://docs.img2.site/api#response-unprocessable), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | chk\_… id | **Body** (application/json, `CheckReviewRequest`, required) staffReviewCheck request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | `approved | rejected` | yes | | | `note` | `string` | no | | **Responses:** `200` The review (`CheckReviewResult`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | 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](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-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 | Parameter | In | Required | Description | | --- | --- | --- | --- | | `id` | path | yes | inc\_… id | **Body** (application/json, `IncidentPatch`, required) staffUpdateIncident request body fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `ended_at` | `string | null` | no | | | `body` | `string` | no | | **Responses:** `200` Updated (`Incident`), `400` [BadRequest](https://docs.img2.site/api#response-badrequest), `401` [Unauthorized](https://docs.img2.site/api#response-unauthorized), `403` [Forbidden](https://docs.img2.site/api#response-forbidden), `404` [NotFound](https://docs.img2.site/api#response-notfound), `409` [Conflict](https://docs.img2.site/api#response-conflict), `413` [PayloadTooLarge](https://docs.img2.site/api#response-payloadtoolarge), `429` [TooManyRequests](https://docs.img2.site/api#response-toomanyrequests), `503` [Unavailable](https://docs.img2.site/api#response-unavailable) --- # Authentication Source: https://docs.img2.site/auth Authentication # API keys on your server, upload tokens in the browser. Your server calls the API with a project API key. Browsers never hold a key: they receive short-lived upload tokens from your server. People manage projects and keys in the dashboard with their own sign-in. Public delivery URLs need no credential at all; protected ones carry a signature instead. ## Project API keys A key looks like `i2s_live_` followed by 36 hex characters and belongs to exactly one project. Send it as `X-Api-Key` or as `Authorization: Bearer`. Keys are created in the dashboard and the full value is shown once. Keep it in a server-side environment variable; never ship it to a browser or a mobile app. A key only reaches its own project. A request for another project's path, file or upload answers `404`, not `403`, so keys cannot probe for other projects. Calling the API with a key ``` # Either header works; send it on every request. curl https://api.img2.site/v1/key \ -H "X-Api-Key: $IMG2SITE_API_KEY" curl https://api.img2.site/v1/projects/my-store/files \ -H "Authorization: Bearer $IMG2SITE_API_KEY" # GET /v1/key answers with the key's own project and scopes: { "key_id": "key_…", "prefix": "i2s_live_3fa", "scopes": ["files:read", "files:write"], "project_id": "proj_…", "project_slug": "my-store", "organization_id": "org_…" } ``` ## Scopes API key scopes and what they allow | Scope | Allows | | --- | --- | | `files:read` | Read the project, its files, upload sessions, watermark presets and usage. | | `files:write` | Create and complete uploads, delete files, save and delete watermark presets. | | `urls:sign` | Mint signed delivery URLs (`POST /v1/projects/{slug}/signed-url`). | | `recipes:read` | Read recipes. | | `recipes:write` | Save and delete recipes. | A key created with no scopes (`scopes: []`) has all of them. A call outside the key's scopes answers `403 forbidden`. The [REST reference](https://docs.img2.site/api) lists the scope each operation needs. ## Upload tokens for browsers The upload router in `@img2site/sdk-server` creates the upload session with your key and hands the browser the session plus an upload token. The token lets the browser finish that one upload and nothing else; it expires with the session and stops working when the key is rotated. See the [quickstart](https://docs.img2.site/quickstart). ## Dashboard sessions Project settings, API keys, signing keys, billing, the audit log and your organization are managed with a dashboard session only. An API key on one of those routes answers `403 browser_session_required`. ## Failures and limits - A missing, unknown or revoked key answers `401 unauthorized`. Keys of a suspended project stop authenticating. - Failed authentications are limited to 10 per minute per credential and per client IP; past that they answer `429 rate_limited` with `Retry-After`. A request whose credential verifies is never refused by this limit. - By default project writes are limited to 60 per minute per project (upload create and complete are exempt), and signed URLs have their own 60 per minute per project. These per-project limits are set per deployment and may be higher on yours; any limit answers `429 rate_limited` with `Retry-After`. - Treat a leaked key as compromised: rotate it in the dashboard, which revokes the old key at once. --- # Signing Source: https://docs.img2.site/signing Signing # Signed delivery URLs are minted by the API and verified at the edge. A signed URL carries `exp`, `kid` and `sig` and stops working when it expires. Protected projects serve nothing else. Public projects accept signed URLs too, but their unsigned URLs keep working, so only a protected project makes access depend on the signature. Mint them on your server; the signing secret never leaves img2site. ## Minting a signed URL `POST /v1/projects/{slug}/signed-url` with an API key that has `urls:sign` (or a dashboard session). The body is `{ file_slug, transform_chain?, kind?, expires_in_seconds? }`; `expires_in_seconds` is 60 to 3600, default 300. The project must be active and the file `ready`. A chain must pass the grammar, and its watermark presets must exist; on a `public` project it must also be in `allowed_transform_chains` (a `protected` project signs any valid chain). `public_url` is the unsigned equivalent, or `null` on a protected project. This endpoint has its own per-project limit on issued URLs (60 per minute by default, configurable per deployment); refused requests do not count against it. Cache signed URLs for their lifetime instead of minting one per page view. Request and response ``` POST https://api.img2.site/v1/projects/my-store/signed-url X-Api-Key: $IMG2SITE_API_KEY (scope urls:sign) Content-Type: application/json { "file_slug": "hero", "transform_chain": "resize/1024x", "expires_in_seconds": 300 } 200 OK { "url": "https://my-store.i2scd.net/resize/1024x/f/hero?exp=2026-09-24T12%3A05%3A00.000Z&kid=kid_…&sig=…", "expires_at": "2026-09-24T12:05:00.000Z", "kid": "kid_…", "kind": "derived", "transform_chain": "resize/1024x", "public_url": "https://my-store.i2scd.net/resize/1024x/f/hero" } ``` With @img2site/sdk-server ``` import { createClient } from "@img2site/sdk-server"; const img2site = createClient({ apiKey: process.env.IMG2SITE_API_KEY!, project: "my-store" }); const { url } = await img2site.signedUrl({ file_slug: "hero", chain: "resize/1024x/format/webp", expires_in_seconds: 600, }); ``` ## URL kinds Signed URL kinds | kind | Path | Default when | | --- | --- | --- | | `derived` | `/{chain}/f/{slug}` | a transform\_chain is given | | `originals` | `/originals/f/{slug}` | no transform\_chain is given | | `raw` | `/download/raw/f/{slug}` | never; needs `raw_download_enabled` (else `403 raw_download_disabled`) | ## Errors when minting Errors of the signed-url endpoint | Status | Code | Cause | | --- | --- | --- | | 400 | `invalid_transform_chain` | The chain does not parse or names an unknown preset. | | 400 | `watermark_preset_not_found` | The chain names a watermark preset that does not exist in the project, or uses the default watermark when the project has none. | | 400 | `transform_chain_not_allowed` | Public projects only: the chain is not in allowed\_transform\_chains. | | 403 | `project_suspended` | The project is suspended. | | 404 | `file_not_found` | No such file, or it was deleted. | | 409 | `file_not_ready` | The file is blocked or not published yet. | | 409 | `signing_key_missing` | The project has no active signing key; rotate one in the dashboard. | | 429 | `rate_limited` | The project exceeded its signed-URL limit (60 per minute by default), or sent ten times that many signing requests. | ## Canonical string The edge verifies `sig` as base64url (no padding) HMAC-SHA256 over the canonical string, with the project signing key named by `kid`, compared in constant time. `HEAD` is verified as `GET`. `{transform_chain}` is the normalized chain (`originals` or `download/raw` for those kinds), `{query_sorted}` is every query parameter except `sig`, percent-encoded and sorted, and `{exp}` is the ISO-8601 expiry. Any change to the chain, slug, query, expiry or key id invalidates the signature. An expired `exp` is rejected before the key is looked up. ``` {METHOD}|{transform_chain}|{file_slug}|{query_sorted}|{exp}|{kid} GET|resize/1024x|hero|exp=2026-09-24T12%3A05%3A00.000Z&kid=kid_abc123|2026-09-24T12:05:00.000Z|kid_abc123 ``` ## Rotating the signing key Rotation creates a new active key and keeps the previous one valid for 24 hours, until `previous_overlap_until`. URLs signed with the previous `kid` keep working during the overlap; after it they answer `403 unknown_kid`, so re-mint anything you still need. Rotation needs a dashboard session. `GET /v1/projects/{slug}/signing-keys` lists key ids and their status, never secrets. Rotation ``` POST https://api.img2.site/v1/projects/my-store/signing-keys/rotate (dashboard session) 200 OK { "active_kid": "kid_new…", "previous_kid": "kid_old…", "previous_overlap_until": "2026-09-25T12:00:00.000Z" } ``` See [delivery URLs](https://docs.img2.site/guides/delivery-urls) for the grammar and the [REST reference](https://docs.img2.site/api#tag-signing) for the full request and response schemas. --- # Error codes Source: https://docs.img2.site/errors Error codes # Branch on the code, show the message, keep the request id. Every API error uses one envelope. `code` is stable and safe to branch on; `message` is for people and may change; `details` is optional structured context. Every response carries `x-request-id`; include it when you contact support. ## Retrying - Retry `429` after `Retry-After`, and `503` with exponential backoff. - Do not retry other `4xx` without changing the request. - Uploads that fail during processing do not return an HTTP error: the session's `status` becomes `failed` with an `error_code` (see the [upload flow](https://docs.img2.site/guides/upload-flow)). - In the SDKs these arrive as `Img2siteError` (server) and `Img2siteUploadError` (browser) with the same `status`, `code` and `details`. Error envelope ``` HTTP/1.1 413 Payload Too Large x-request-id: 0f6c… { "error": { "code": "file_too_large", "message": "…", "details": { "max_bytes": 100000000 } } } ``` ## API errors API error codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | The body or query failed validation (unknown fields are refused). details says which field. | | 400 | `invalid_json` | The body is not valid JSON. | | 400 | `invalid_cursor` | A pagination cursor was altered or truncated. Restart from the first page. | | 400 | `invalid_file_slug` | file\_slug must match ^\[a-z0-9\]\[a-z0-9.\_-\]{0,127}$. | | 400 | `invalid_transform_chain` | The chain does not parse or names an unknown preset; details.reason says why. | | 400 | `watermark_preset_not_found` | A watermark preset named in a chain or setting does not exist in this project. | | 400 | `transform_chain_not_allowed` | Minting a signed URL on a public project with a chain outside allowed\_transform\_chains. | | 400 | `invalid_range` | Usage from must be before to. | | 401 | `unauthorized` | Missing, unknown or revoked credential. | | 403 | `forbidden` | The API key lacks the scope this operation needs. | | 403 | `browser_session_required` | This route is for dashboard sessions only (keys, settings, billing, audit). | | 403 | `project_suspended` | The project is suspended. | | 403 | `project_suspended_by_staff` | Only img2site staff can reactivate this project. | | 403 | `storage_quota_exceeded` | The upload would exceed the project's storage limit. | | 403 | `raw_download_disabled` | A raw signed URL was requested but the project has raw downloads off. | | 404 | `not_found` | The resource does not exist or belongs to another project or organization. | | 404 | `file_not_found / upload_not_found` | No such file or upload session in this project. | | 409 | `project_exists` | The project slug is taken. | | 409 | `api_key_quota_exceeded` | The project already has 100 active API keys. | | 409 | `file_not_ready` | The file is not ready (for example blocked). | | 409 | `signing_key_missing` | The project has no active signing key; rotate one. | | 409 | `default_watermark_in_use` | The preset is the project's default watermark; change the default first. | | 409 | `recipe_name_taken` | Another recipe of the project has this name. | | 413 | `payload_too_large` | The request body is over the route's limit (64 KiB for most writes). | | 429 | `rate_limited` | Too many requests or failed authentications; wait for Retry-After seconds. | | 503 | `auth_unavailable / storage_unavailable` | A dependency is briefly unavailable. Retry with backoff. | ## Upload errors Upload endpoint error codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `unsupported_content_type` | content\_type is not one of the supported image types. | | 400 | `invalid_part_size / parts_required` | Multipart parts are smaller than 5 MiB, or complete was called without the part list. | | 400 | `invalid_parts` | The multipart part list is not every part 1..N once with a non-empty ETag, names parts not uploaded, has a part other than the last under 5 MiB, or does not add up to size\_bytes. The upload is left as it was; send the full list. | | 400 | `multipart_complete_failed` | The storage backend refused to assemble the parts (for example a wrong ETag); details.reason. | | 400 | `body_length_mismatch` | The proxy PUT sent fewer bytes than size\_bytes. | | 401 | `invalid_signature / signature_expired` | The proxy PUT URL was altered, or is past its exp (the session's 15 minutes). Create a new session. | | 409 | `ingest_object_missing` | Complete was called before the bytes arrived. | | 409 | `ingest_size_mismatch` | The stored object is not size\_bytes long. | | 409 | `upload_not_pending` | The session is already past the step you called. | | 411 | `length_required` | The proxy PUT has no Content-Length. | | 413 | `file_too_large` | size\_bytes is over the upload limit (details.max\_bytes). | | 415 | `content_type_mismatch` | The proxy PUT's Content-Type differs from the session's. | | 422 | `sha256_mismatch` | The bytes do not match the declared sha256. | ## Delivery errors Delivery hosts answer `404 not_found` for anything they cannot serve and `403` with `project_suspended`, `quota_exceeded`, `file_blocked`, `signature_required`, `invalid_signature`, `signature_expired`, `unknown_kid` or `transform_chain_not_allowed`. The [delivery guide](https://docs.img2.site/guides/delivery-urls) lists them in the order they are checked. Use a placeholder image when a delivery URL fails. --- # Troubleshooting Source: https://docs.img2.site/troubleshooting Troubleshooting # Common pitfalls and how to get past them. Find the symptom, then the fix. Error codes are explained on the [errors](https://docs.img2.site/errors) page. When you contact support, include the `x-request-id` of the failing response. ## Uploads Upload symptoms and fixes | Symptom | Likely cause | Fix | | --- | --- | --- | | Creating an SVG upload answers 400 unsupported\_content\_type or 413 file\_too\_large | SVG uploads are not enabled on this deployment (400), or the SVG is above 2 MB (413). | Upload the image as PNG or WebP, or reduce the SVG below 2 MB. | | The PUT to put\_url answers 403 from the storage host | The request headers differ from the signed ones, or the URL expired (15 minutes). | Send exactly the returned headers and the exact size\_bytes; create a new session if it expired. | | The browser PUT fails with a CORS or network error | A network or extension blocks the storage host. | Leave `mode` unset so the SDK falls back to the upload proxy, or pass `mode: "proxy"`. | | Multipart fails with etag\_missing | The browser cannot read the ETag response header. | Use the SDK defaults; report it to support if it persists. | | The session stays created | The bytes never arrived. | Check the PUT response, then call `complete`; the session expires after 15 minutes. | | The session is failed | Processing refused the file; see `error_code`. | See the [processing errors](https://docs.img2.site/guides/upload-flow); animated images and HEIC are refused, as are JPEG and PNG images over 128 megapixels and other formats over 50 megapixels (AVIF and some WebP less). SVGs are refused with embedded bitmap images or a DOCTYPE, or when too complex to render safely. `image_too_large` can also mean the image is too costly to process, for example a palette PNG that becomes too large when re-encoded. | | onUploadComplete ran twice | The browser repeated the complete call. | Make the hook idempotent, keyed on upload\_id. | ## Authentication Authentication symptoms and fixes | Symptom | Likely cause | Fix | | --- | --- | --- | | `401 unauthorized` | Missing, mistyped, revoked or rotated key. | Send `X-Api-Key` or `Authorization: Bearer` with the current key; `GET /v1/key` shows what a key can do. | | `403 forbidden` | The key lacks a scope. | Create a key with the scope (see [authentication](https://docs.img2.site/auth)) or one without scopes. | | `403 browser_session_required` | The route is dashboard-only. | Change keys, settings and billing in the dashboard. | | `404` on a project that exists | The key belongs to another project. | Use the key of the project in the path. | | `429 rate_limited` | Too many failed authentications or writes. | Fix the credential, then retry after Retry-After. | ## Delivery Delivery symptoms and fixes | Symptom | Likely cause | Fix | | --- | --- | --- | | `404` for a URL with `?v=` or another query string | Unsigned delivery URLs may not carry a query string. | Drop it; a re-upload updates the URL on its own. | | `404` for a transform URL | The chain does not parse, uses an unknown preset, repeats an operation, or /f/ is missing. | Check it against the [grammar](https://docs.img2.site/guides/delivery-urls). | | `403 signature_required` | The project is protected. | Serve signed URLs minted by your server. | | `403 signature_expired` | The signed URL is past exp. | Mint a new one; pick an expiry that covers the page lifetime. | | `403 unknown_kid` | The signing key was rotated more than 24 hours ago. | Mint the URL again. | | `403 file_blocked` | Moderation blocked the file. | Show a fallback; the `file.unblocked` webhook tells you when it is served again. | | `403 quota_exceeded` | The organization has no credits. | Add credits in billing (see [usage and billing](https://docs.img2.site/guides/usage-and-billing)). | | An old version after a re-upload | Your browser cache (up to five minutes), or a delivery server's file cache (up to 30 seconds). | Wait or reload; the CDN purge after a re-upload is queued, not instant. | | A deleted or blocked file still loads | Delivery servers cache file lookups for up to 30 seconds, and the CDN purge is queued. | Wait a minute; browsers may keep their copy for up to five minutes. | ## Webhooks - `signature_mismatch`: verify the raw body; a body parsed and serialized again no longer matches. - `timestamp_out_of_tolerance`: synchronize your server clock. - No events at all: the project needs a `callback_url` that resolves to a public https address. [Webhook guide](https://docs.img2.site/webhooks) ## Usage - Today's hours appear after the rollup at five past each hour. - The credit balance changes once a day, after 00:20 UTC. - Views served from the CDN cache are not metered. [Usage guide](https://docs.img2.site/guides/usage-and-billing)