Reference / v1

Puzzle API — Getting started (important)

Create a puz_live_ key in the console, then POST jobs. Production host is https://api.puzzle-cloud.com; the machine-readable twin of this page is /openapi.json. This site also mirrors the generation, balance, models and job-status endpoints under /api/v1 so a key you just created can be verified here.

Available balance starts at $0.00. A valid key with no credit returns 402 insufficient_funds — that is expected, not a broken endpoint. Nothing is charged until a job completes.

1. Models and Playground

Two models are live today; three more are announced and answer 422 model_unavailable until they ship. Every model runs on Google Gemini behind Puzzle's queue, pool and billing. Try any of them without writing code in the console Playground (Playground) — it submits through the same domain step as the API, so what you see there is what POST /v1/*-generations does. GET /v1/models returns the same catalog with parameters and prices.

ModelStatusModes
nano-banana-v2livetext-to-image (POST /v1/text-generations) and image-to-image (POST /v1/url-generations); aspect ratios 1:1, 16:9, 9:16, 4:3; resolutions 1K (default), 2K, 4K
nano-banana-prolivetext-to-image (POST /v1/text-generations) and image-to-image (POST /v1/url-generations); aspect ratios 1:1, 16:9, 9:16, 4:3; resolutions 1K (default), 2K, 4K
omnicoming soon422 model_unavailable on generation; listed in coming_soon of GET /v1/models with empty parameter sets
veocoming soon422 model_unavailable on generation; listed in coming_soon of GET /v1/models with empty parameter sets
geminicoming soon422 model_unavailable on generation; listed in coming_soon of GET /v1/models with empty parameter sets

Coming soon. omni, veo, gemini are Puzzle model ids, not consumer apps. They use the same request envelope when they ship — nothing to change in your integration except the model value. Any other id answers 400 invalid_request.

2. Where to see prices

Prices are USD per generated image and depend on model, mode and resolution. Three places, always the same numbers: the pricing page, the public GET /v1/pricing (no key, cached 60 s), and POST /v1/quote, which prices one concrete request and returns the exact estimated_max a generation would reserve. File-upload fees (per file and per GiB-day of stored source images) live under uploads of the same JSON; 0.000000 means free. Nothing is charged until a job completes; failed and cancelled jobs release their reservation in full. Never copy prices into your code — read them.

3. Create and protect your key

Keys are created in the console under API keys. A key looks like puz_live_…, is shown once and stored only as a SHA-256 hash — copy it when it appears; if you lose it, revoke it and create a new one. Keys belong to a workspace: balance, jobs, rate limit and usage are per workspace, and every key of the workspace sees the same jobs.

  • Spend limit — a USD cap per key, per daily / monthly / total period (UTC day, UTC month, lifetime). It counts what the key's jobs have charged plus what they currently hold in reservation; a request that would cross it answers 402 key_spend_limit_exceeded with the limit, the amount used and the price of the refused request in the message.
  • Allowed models — an allowlist of model ids; an empty list means every live model. A request for another model answers 403 model_not_allowed.
  • Revoke — immediate; every later request with that key answers 401 unauthorized.
  • Last used — the console shows when each key was last used and how many jobs it created, so unused keys are easy to spot and revoke.
  • Keep keys on your server. Browsers and mobile apps should call your backend, which calls Puzzle. The signed result.url is safe to hand to end users — it needs no key.

4. Required headers

HeaderOnNotes
Authorizationall key endpointsBearer puz_live_… — the canonical way to send the key
X-Api-Keyall key endpointsAlternative to Authorization for SDKs that only speak x-api-key; same key
Content-TypePOSTapplication/json on JSON bodies; multipart/form-data on POST /v1/uploads when sending file
Idempotency-KeyPOST generations≤ 128 chars, unique per workspace. Same key + same body → the stored job again (202, Idempotent-Replayed: true); same key + different body → 409 idempotency_conflict

5. Logs and job history

Every accepted job is visible in the console under Run history (status, price, timestamps, provider attempts) and through GET /v1/jobs / GET /v1/jobs/{id}. Every API response carries X-Request-Id (req_…); on a job it equals the job's request_id, and error bodies repeat it as error.request_id. Log it with every call — it is what support needs to find your request. Webhook deliveries — status, attempt count, last HTTP status and latency, next attempt time — are listed under Webhooks.

6. Data retention

Generated images are kept for 7 days after completion on the current storage; result.expires_at on every completed job is the exact deadline and the signed download link is valid for exactly that time. After it the object is purged, the link answers 410 output_expired, and the job keeps its metadata (status, price, timestamps). Download what you need before then. Prompts and source URLs are stored with the job. A public url source is fetched at run time and not stored; an upload_id source lives until its own expires_at or until you DELETE it. Puzzle does not use prompts or outputs to train models.

7. Asynchronous model

Generation is asynchronous. A generation request answers 202 with a job whose status is queued — accepted and paid for (reserved), not finished. Statuses: queued → processing → retry_scheduled → completed → failed → cancelled (see Jobs & results for the exact transitions). Get the outcome either by polling GET /v1/jobs/{id} with backoff — reads are not rate-limited — or by passing callback_url and receiving a signed job.completed / job.failed webhook. Only a completed job fetched with GET /v1/jobs/{id} carries result.

8. Rate limits

60 requests per 60-second sliding window per workspace by default (Puzzle can set a different limit for your workspace). POST /v1/text-generations, POST /v1/url-generations and POST /v1/uploads count; quotes, job reads, usage, balance, models, pricing, downloads and upload GET/DELETE do not. Every counted response after authentication carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 rate_limited adds Retry-After. Details under Rate limits & quotas.

9. Support

Telegram @PUZZLE_Support_AI for integration questions, bank transfers, contracts and higher limits; WeChat and WhatsApp contacts are arranged through the same Telegram. Quote the request_id or job id. There is no e-mail or phone support channel published.

10. Honest expectations

  • Generation runs on Google Gemini. Puzzle routes each attempt across a pool of provider projects with a circuit breaker; transient provider failures (5xx, quota, rate limit, network, no capacity) move the job to retry_scheduled and it is retried with exponential backoff — up to 4 attempts in total.
  • A job that still fails releases its reservation: failed and cancelled jobs are never charged. Only completed jobs are.
  • Safety-filter rejections, unreachable source images and malformed requests are not retried — they fail immediately with a stable error.code.
  • Availability targets and SLAs exist only in a signed contract; the public API ships with no uptime guarantee and no status page. X-Request-Id on every response is your handle for incidents.
  • A workspace runs a limited number of jobs concurrently, so a burst of accepted jobs is worked through in order (oldest first) rather than all at once.

Endpoint overview

EndpointAuthNotes
POST /v1/text-generationsAPI keyCreate a text-to-image job — counts toward the rate limit
POST /v1/url-generationsAPI keyCreate an image-to-image job — counts toward the rate limit
POST /v1/uploadsAPI keyStore a source image for image-to-image — counts toward the rate limit
GET /v1/uploadsAPI keyList live uploads of the workspace
GET /v1/uploads/{id}API keyGet one upload and a fresh signed link
DELETE /v1/uploads/{id}API keyDelete an upload and its stored object
POST /v1/quoteAPI keyPrice a request without creating a job
GET /v1/jobsAPI keyList jobs of the workspace (newest first, cursor-paginated)
GET /v1/jobs/{id}API keyGet one job (completed jobs carry the download link)
POST /v1/jobs/{id}/cancelAPI keyCancel a queued job (reservation released)
GET /v1/usageAPI keyUsage and spend aggregates over UTC days
GET /v1/balanceAPI keyAvailable and reserved balance
GET /v1/modelsnoneModel catalog with parameters and prices
GET /v1/pricingnoneActive price list (USD per image)
GET /v1/outputs/{key}signed linkDownload a generated image via its signed link

All paths live on https://api.puzzle-cloud.com. This site mirrors /v1/text-generations, /v1/url-generations, /v1/balance, /v1/models, /v1/jobs/{id} under /api/v1 so a freshly created key can be verified here. GET /v1/outputs/{key} also accepts HEAD; unknown paths answer 404 invalid_request, a wrong method 405 method_not_allowed, and every endpoint answers OPTIONS with permissive CORS.

Authentication

Send the key as a Bearer token (canonical) or in the X-Api-Key header. Only a SHA-256 hash of the key is stored; the plaintext is shown once in the console — if you lose it, revoke and create a new one. Missing, malformed, unknown or revoked keys return 401 unauthorized. Public endpoints (models, pricing, output downloads) need no key.

# canonical
curl -sS "https://api.puzzle-cloud.com/v1/balance" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

# for SDKs that only speak x-api-key
curl -sS "https://api.puzzle-cloud.com/v1/balance" \
  -H "X-Api-Key: $PUZZLE_API_KEY"

Keys belong to a workspace: balance, jobs, rate limit and usage are per workspace, and every key of the workspace sees the same jobs. In the console a key can get a spend limit (USD per UTC day, UTC month or lifetime, counting charged plus reserved amounts — 402 key_spend_limit_exceeded when a request would cross it) and an allowed-models list (403 model_not_allowed otherwise).

HeaderOnNotes
Authorizationall key endpointsBearer puz_live_… — the canonical way to send the key
X-Api-Keyall key endpointsAlternative to Authorization for SDKs that only speak x-api-key; same key
Content-TypePOSTapplication/json on JSON bodies; multipart/form-data on POST /v1/uploads when sending file
Idempotency-KeyPOST generations≤ 128 chars, unique per workspace. Same key + same body → the stored job again (202, Idempotent-Replayed: true); same key + different body → 409 idempotency_conflict

Errors of this endpoint

StatusCodeWhen
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
402key_spend_limit_exceededThis request would push the key past its daily / monthly / total spend limit (charged + reserved)
403model_not_allowedThe key's allowed-models list excludes this model

401 is about the key itself — missing, not puz_live_…, unknown or revoked; the message says which. 402 key_spend_limit_exceeded and 403 model_not_allowed are policies you set on the key in the console; nothing is reserved or charged when they fire. A valid key with an empty balance gets 402 insufficient_funds on generations — expected, not an outage.

Request flow

One job, end to end: quote (optional) → POST → 202 → poll or webhook → result → expires_at. Everything on the accept path — authentication, rate limit, allowlist, quote, max_price, key spend limit, reservation, job insert and the job.queued webhook — happens in one database transaction, so a request is either fully accepted (202) or rejected with nothing reserved.

flow
your server                              Puzzle API (https://api.puzzle-cloud.com)
───────────                              ─────────────────────────────────────────────
POST /v1/quote  ───────────────────────▶ price from the active list      200 { estimated_max }
                                          (nothing reserved, not rate-limited)

POST /v1/text-generations ─────────────▶ auth → rate limit → allowlist → quote → max_price
  Idempotency-Key: order-8812-cover        → key spend limit → reserve estimated_max → insert job
  { model, prompt, user, max_price }       → queue job.queued webhook          all in ONE transaction
                ◀───────────────────────  202 { id, status: "queued", price.estimated_max }
                                          X-Request-Id, RateLimit-Limit/Remaining/Reset

                     worker claims job ─▶ processing ─▶ Google Gemini ─▶ store image
                                          (retry_scheduled + backoff on transient failures, ≤ 4 attempts)

GET /v1/jobs/{id}  (poll, backoff) ────▶ 200 { status: "processing" }        or
callback_url   ◀────────────────────────  POST job.completed / job.failed     (signed, retried)

GET /v1/jobs/{id} ─────────────────────▶ 200 { status: "completed", price.charged, result.url, result.expires_at }
GET result.url  (no API key) ──────────▶ the image                            until expires_at (7 days)
                                          then 410 output_expired

Submit, poll, download

# 1. submit (202 → job id)
JOB=$(curl -sS -X POST "https://api.puzzle-cloud.com/v1/text-generations" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"nano-banana-pro","prompt":"A cyan puzzle piece"}' | jq -r .id)

# 2. poll until completed / failed / cancelled
while :; do
  BODY=$(curl -sS "https://api.puzzle-cloud.com/v1/jobs/$JOB" -H "Authorization: Bearer $PUZZLE_API_KEY")
  STATUS=$(echo "$BODY" | jq -r .status)
  case "$STATUS" in completed|failed|cancelled) break;; esac
  sleep 2
done

# 3. download before result.expires_at
curl -sS -o image.png "$(echo "$BODY" | jq -r .result.url)"

Idempotency, max_price, user

  • Idempotency-Key (≤ 128 chars, unique per workspace) makes a retry safe: the same key with the same body returns the stored job again — 202, header Idempotent-Replayed: true, nothing reserved twice; the same key with a different body answers 409 idempotency_conflict. Use your own order or task id. Two concurrent requests with the same key resolve the same way — one wins the insert, the other gets the winner's job.
  • max_price is a USD ceiling for this job. If the quoted price is higher the API answers 409 price_exceeds_max with both numbers in the message and nothing reserved. It is a caller-side guard: it is not part of the job and does not affect idempotency hashing.
  • user (≤ 128 chars) is your end-user id, stored verbatim and returned on the job. It is a filter of GET /v1/jobs and a group_by dimension of GET /v1/usage, which turns Puzzle into a per-customer meter without any bookkeeping on your side.
  • callback_url (HTTPS, public address) receives job.queued, job.completed, job.failed and job.cancelled for this job only; the endpoint saved in the console receives job.completed and job.failed for every job of the workspace.

Endpoints

One subsection per path. Every error listed per endpoint uses the common envelope (see Errors); 405 method_not_allowed and 500 internal_error can occur on any path and are omitted from the per-endpoint lists. Endpoints marked "API key" accept Authorization: Bearer or X-Api-Key.

Text to image

POST/v1/text-generations

Create a text-to-image job. The request is validated, priced from the active price list, its worst-case price is reserved from your available balance and the job is queued — all in one transaction. Live models: nano-banana-v2, nano-banana-pro.

202 means accepted and queued, not finished: the body is the job object, the response carries X-Request-Id and RateLimit-* headers, and the worst-case price is already reserved. Poll GET /v1/jobs/{id} or pass callback_url. Send Idempotency-Key to retry safely.

FieldTypeNotes
modelstringreqnano-banana-v2, nano-banana-pro. Unknown id → 400; omni, veo, gemini → 422 model_unavailable
promptstringreqGeneration prompt, ≤ 4000 characters
aspect_ratiostring1:1, 16:9, 9:16, 4:3
resolutionstring1K (default), 2K, 4K — sets the price, see POST /v1/quote
callback_urlstringHTTPS webhook for this job: receives job.queued, job.completed, job.failed, job.cancelled. Must resolve to a public address
userstringYour end-user id (≤ 128 chars, stored verbatim). Returned on the job, filterable in GET /v1/jobs, a group_by of GET /v1/usage
max_pricestringUSD ceiling as a decimal string, e.g. "0.25" (> 0). If the quoted price is higher → 409 price_exceeds_max, nothing charged
curl -sS -X POST "https://api.puzzle-cloud.com/v1/text-generations" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8812-cover" \
  -d '{
    "model": "nano-banana-pro",
    "prompt": "A cyan puzzle piece, studio light",
    "aspect_ratio": "1:1",
    "resolution": "1K",
    "user": "cust-42",
    "max_price": "0.25"
  }'

Response 202. The same job object GET /v1/jobs/{id} returns; result is null until completed.

{
  "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
  "request_id": "req_9a1b2c3d4e5f60718293a4b5",
  "status": "queued",
  "model": "nano-banana-pro",
  "kind": "text",
  "user": "cust-42",
  "settings": {
    "aspect_ratio": "1:1",
    "resolution": "1K"
  },
  "price": {
    "estimated_max": "0.191430",
    "charged": null,
    "currency": "USD"
  },
  "result": null,
  "error": null,
  "created_at": "2026-09-17T12:00:00.000Z",
  "started_at": null,
  "completed_at": null,
  "failed_at": null
}

Errors of this endpoint

StatusCodeWhen
400invalid_jsonBody is not a JSON object
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
402insufficient_fundsAvailable balance is below the worst-case price of this generation or the upload fee — nothing charged
402key_spend_limit_exceededThis request would push the key past its daily / monthly / total spend limit (charged + reserved)
403model_not_allowedThe key's allowed-models list excludes this model
409idempotency_conflictIdempotency-Key reused with a different request body
409price_exceeds_maxQuoted price is above the max_price you sent — nothing charged
422model_unavailableOmni / Veo / Gemini — announced, not served yet
429rate_limitedWorkspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers
503pricing_unavailableNo active price list, or no active price for this model and resolution

Use cases

  • Product imagery on demand: one job per SKU with user set to the merchant id, so GET /v1/usage?group_by=user becomes the invoice.
  • Bulk generation from a queue: reuse your own order id as Idempotency-Key so a crashed worker can resend without paying twice.
  • Cost-capped features: send max_price so an unexpected 4K price is refused with 409 instead of being reserved.

Image to image

POST/v1/url-generations

Edit or restyle an existing image. Same fields and behaviour as text-to-image plus a source: either a public HTTPS url (alias image_url) or an upload_id from POST /v1/uploads (send one, not both). A public URL is downloaded at run time: it must be reachable on a public address, be one of image/png, image/jpeg, image/webp, image/gif, image/heic, image/heif and at most 20 MB; the download times out after 20 s. An upload_id is read from Puzzle storage and must still be live in this workspace. A source that cannot be fetched fails the job (error.code source_url_* or upload_not_found) and releases the reservation.

FieldTypeNotes
modelstringreqSame live models as text to image
promptstringreqEdit / restyle instruction, ≤ 4000 characters
urlstringHTTPS source image. Alias: image_url (url wins when both are sent). Omit when sending upload_id
image_urlstringSame as url
upload_idstringId of a live upload (upl_…) from POST /v1/uploads. Send either url or upload_id, not both. The worker reads the file from Puzzle storage — no public URL needed
aspect_ratiostring1:1, 16:9, 9:16, 4:3
resolutionstring1K (default), 2K, 4K
callback_urlstringHTTPS webhook for this job: receives job.queued, job.completed, job.failed, job.cancelled. Must resolve to a public address
userstringYour end-user id (≤ 128 chars, stored verbatim). Returned on the job, filterable in GET /v1/jobs, a group_by of GET /v1/usage
max_pricestringUSD ceiling as a decimal string, e.g. "0.25" (> 0). If the quoted price is higher → 409 price_exceeds_max, nothing charged
curl -sS -X POST "https://api.puzzle-cloud.com/v1/url-generations" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "nano-banana-pro",
    "prompt": "Restyle as cyan studio light, keep the composition",
    "url": "https://example.com/source.png",
    "resolution": "2K",
    "callback_url": "https://example.com/puzzle/webhook"
  }'

Response 202. Job object with kind: "url"; the source URL itself is never echoed back.

{
  "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
  "request_id": "req_9a1b2c3d4e5f60718293a4b5",
  "status": "queued",
  "model": "nano-banana-pro",
  "kind": "url",
  "user": null,
  "settings": {
    "resolution": "2K"
  },
  "price": {
    "estimated_max": "0.191430",
    "charged": null,
    "currency": "USD"
  },
  "result": null,
  "error": null,
  "created_at": "2026-09-17T12:00:00.000Z",
  "started_at": null,
  "completed_at": null,
  "failed_at": null
}

Errors of this endpoint

StatusCodeWhen
400invalid_jsonBody is not a JSON object
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
402insufficient_fundsAvailable balance is below the worst-case price of this generation or the upload fee — nothing charged
402key_spend_limit_exceededThis request would push the key past its daily / monthly / total spend limit (charged + reserved)
403model_not_allowedThe key's allowed-models list excludes this model
409idempotency_conflictIdempotency-Key reused with a different request body
409price_exceeds_maxQuoted price is above the max_price you sent — nothing charged
422model_unavailableOmni / Veo / Gemini — announced, not served yet
429rate_limitedWorkspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers
503pricing_unavailableNo active price list, or no active price for this model and resolution

Use cases

  • Restyle user uploads: POST /v1/uploads once, then pass upload_id on every variation — no public CDN required.
  • Or store the file on your CDN, pass its HTTPS URL, receive the result via callback_url.
  • Variations of a hero image at a different aspect_ratio or resolution without re-prompting from scratch.

Upload a source image

POST/v1/uploads

Store a source image once and reference it from image-to-image as upload_id. Send multipart/form-data field file, JSON { "data": "<base64>" }, or JSON { "url": "https://…" } (Puzzle fetches the URL with the same SSRF-pinned transport as webhooks). Bytes are sniffed: only image/png, image/jpeg, image/webp are accepted. 201 returns the upload with a signed url (same /v1/outputs/{key}?exp&sig scheme as generated results) valid until expires_at.

The operator can turn **new** uploads off (503 uploads_disabled); files already stored stay readable and usable. Per-workspace file-count, byte and TTL caps are editable in /admin (null override = global). Fees — if any — are upload_fee at accept and a daily storage_fee per GiB; live numbers are GET /v1/pricing.uploads (0.000000 = free). An unpaid storage day does not delete files: it shortens every live upload's expiry to at most 24 hours and the response may include storage_warning. GET /v1/uploads lists live uploads, newest first.

FieldTypeNotes
filefileMultipart field. PNG, JPEG or WebP; sniffed from magic bytes, Content-Type is never trusted
datastringJSON alternative: base64-encoded image bytes (data: URLs accepted)
urlstringJSON alternative: public https URL Puzzle fetches (SSRF-pinned). Send one of file / data / url
mime_typestringInformational with data; the sniffer decides the stored type
ttl_hoursintegerRequested lifetime in hours; omitted → platform default. Capped by the workspace max. Live defaults: GET /v1/pricing.uploads
userstringYour end-user id (≤ 128 chars), stored on the upload
curl -sS -X POST "https://api.puzzle-cloud.com/v1/uploads" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" \
  -F "file=@source.png" \
  -F "ttl_hours=24"

# JSON base64 alternative
curl -sS -X POST "https://api.puzzle-cloud.com/v1/uploads" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data":"<base64 png>","mime_type":"image/png","user":"cust-42"}'

# list live uploads
curl -sS "https://api.puzzle-cloud.com/v1/uploads?limit=20" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 201. The upload object; url is a signed download link. storage_warning is present only when the next daily storage fee would exceed the available balance.

{
  "id": "upl_4a5b6c7d8e9f0a1b2c3d4e5f",
  "url": "https://api.puzzle-cloud.com/v1/outputs/uploads/2026/09/upl_4a5b6c7d8e9f0a1b2c3d4e5f.png?exp=1789732800&sig=p7QmN2w8R5cYv0KsT3hUaL6nX9dEiB1fZrWjGo",
  "mime_type": "image/png",
  "size_bytes": 214,
  "width": 1,
  "height": 1,
  "created_at": "2026-09-17T12:00:00.000Z",
  "expires_at": "2026-09-18T12:00:00.000Z",
  "user": "cust-42"
}

Errors of this endpoint

StatusCodeWhen
400invalid_jsonBody is not a JSON object
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
402insufficient_fundsAvailable balance is below the worst-case price of this generation or the upload fee — nothing charged
409upload_quota_exceededThis upload would exceed the workspace file-count or byte quota — delete some uploads or wait for them to expire
413upload_too_largeFile exceeds the per-file byte limit (see GET /v1/pricing.uploads.max_file_bytes)
415unsupported_media_typeBytes are not a recognised PNG / JPEG / WebP, or that type is not in the operator allow-list
429rate_limitedWorkspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers
503uploads_disabledPOST /v1/uploads is turned off by the operator; existing uploads stay readable and usable — pass a public url instead

Use cases

  • Accept a user photo in your app, upload it here, then run several image-to-image jobs against the same upload_id.
  • Keep source images off your own CDN when they only need to live as long as the edit session (ttl_hours).

Get or delete an upload

GET/v1/uploads/{id}

Fetch one live upload of this workspace. The signed url is minted fresh on every call and valid until expires_at. Deleted, expired or purged ids answer 404 upload_not_found. DELETE (204) soft-deletes the row and removes the object; it no longer counts toward the workspace quota. List + delete do not count toward the rate limit.

curl -sS "https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

# delete — 204, no body
curl -sS -X DELETE "https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. Same object as POST /v1/uploads.

{
  "id": "upl_4a5b6c7d8e9f0a1b2c3d4e5f",
  "url": "https://api.puzzle-cloud.com/v1/outputs/uploads/2026/09/upl_4a5b6c7d8e9f0a1b2c3d4e5f.png?exp=1789732800&sig=p7QmN2w8R5cYv0KsT3hUaL6nX9dEiB1fZrWjGo",
  "mime_type": "image/png",
  "size_bytes": 214,
  "width": 1,
  "height": 1,
  "created_at": "2026-09-17T12:00:00.000Z",
  "expires_at": "2026-09-18T12:00:00.000Z",
  "user": "cust-42"
}

Errors of this endpoint

StatusCodeWhen
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
404upload_not_foundNo live upload with that id in your workspace (deleted, expired, purged, or never existed); also on image-to-image when upload_id is gone

Use cases

  • Hand url to a preview in your UI without proxying bytes through your server.
  • Delete as soon as the edit session ends so the file stops occupying quota and accruing storage fees.

Quote

POST/v1/quote

Price a request before sending it. Returns the worst-case price a generation with these parameters would reserve — the same number as price.estimated_max on the job — and the id of the price-list version it came from. Nothing is created, reserved or charged, and the call does not count toward the rate limit.

To make the API refuse instead of accepting an unexpected price, send max_price with the generation itself: a quoted price above it answers 409 price_exceeds_max and nothing is charged.

FieldTypeNotes
modelstringreqnano-banana-v2, nano-banana-pro
kindstringtext (default) or url — text-to-image vs image-to-image
resolutionstring1K (default), 2K, 4K
aspect_ratiostringAccepted for symmetry with generation bodies; does not affect the price
curl -sS -X POST "https://api.puzzle-cloud.com/v1/quote" \
  -H "Authorization: Bearer $PUZZLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "nano-banana-pro",
    "kind": "text",
    "resolution": "2K"
  }'

Response 200. Prices in the examples are illustrative — the numbers that apply are those of GET /v1/pricing at the time of the request.

{
  "model": "nano-banana-pro",
  "kind": "text",
  "resolution": "2K",
  "estimated_max": "0.191430",
  "currency": "USD",
  "pricing_version": "pv_5e6f70819a2b3c4d5e6f7081"
}

Errors of this endpoint

StatusCodeWhen
400invalid_jsonBody is not a JSON object
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
422model_unavailableOmni / Veo / Gemini — announced, not served yet
503pricing_unavailableNo active price list, or no active price for this model and resolution

Use cases

  • Show the exact price to your own user before they confirm a generation.
  • Nightly check that your margins still hold when Puzzle publishes a new price list (pricing_version changes).

Job history

GET/v1/jobs

Jobs of your workspace, newest first, keyset-paginated on (created_at, id): pass next_cursor back as cursor until has_more is false. Every key of the workspace sees the same jobs. The list omits result (always null) — fetch a single job for a download link.

ParameterTypeNotes
statusstringqueued, processing, retry_scheduled, completed, failed, cancelled
modelstringExact model id
userstringJobs created with this user value
created_afterstringISO 8601 date-time, inclusive
created_beforestringISO 8601 date-time, exclusive
limitinteger1–100, default 20
cursorstringOpaque next_cursor of the previous page
curl -sS "https://api.puzzle-cloud.com/v1/jobs?status=completed&user=cust-42&limit=50" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. data[] are job objects with result: null; next_cursor is opaque.

{
  "data": [
    {
      "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
      "request_id": "req_9a1b2c3d4e5f60718293a4b5",
      "status": "completed",
      "model": "nano-banana-pro",
      "kind": "text",
      "user": "cust-42",
      "settings": {
        "aspect_ratio": "1:1",
        "resolution": "1K"
      },
      "price": {
        "estimated_max": "0.191430",
        "charged": "0.191430",
        "currency": "USD"
      },
      "result": null,
      "error": null,
      "created_at": "2026-09-17T12:00:00.000Z",
      "started_at": "2026-09-17T12:00:02.000Z",
      "completed_at": "2026-09-17T12:00:09.000Z",
      "failed_at": null
    },
    {
      "id": "job_7d21c0aa9e4b5f3c8d6e1f02",
      "request_id": "req_0b1c2d3e4f5061728394a5b6",
      "status": "failed",
      "model": "nano-banana-pro",
      "kind": "text",
      "user": "cust-42",
      "settings": {
        "aspect_ratio": "1:1",
        "resolution": "1K"
      },
      "price": {
        "estimated_max": "0.191430",
        "charged": null,
        "currency": "USD"
      },
      "result": null,
      "error": {
        "code": "provider_server",
        "message": "The generation backend failed. Retries exhausted; you were not charged."
      },
      "created_at": "2026-09-17T11:58:12.000Z",
      "started_at": "2026-09-17T11:58:13.000Z",
      "completed_at": null,
      "failed_at": "2026-09-17T12:07:41.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0xNyAxMTo1ODoxMi4wMDAwMDArMDAiLCJpIjoiam9iXzdkMjFjMGFhOWU0YjVmM2M4ZDZlMWYwMiJ9"
}

Errors of this endpoint

StatusCodeWhen
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key

Use cases

  • Reconcile your database with Puzzle after an outage: created_after the last id you stored, walk the cursor.
  • Customer statements: user=<id>&status=completed for a billing period (see Reporting).
  • Alerting: status=failed&created_after=<1 h ago> on a schedule.

Job status

GET/v1/jobs/{id}

Poll one job. A completed job carries price.charged and result with a signed download link that is minted fresh on every call and valid until result.expires_at; a failed job carries error (code, message) and its reservation has been released. Jobs are created only after the price was reserved, so an id you never got back — or another workspace's job — is 404 job_not_found. Reads do not count toward the rate limit.

curl -sS "https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. Completed job — the only response that carries result. X-Request-Id equals the job's request_id.

{
  "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
  "request_id": "req_9a1b2c3d4e5f60718293a4b5",
  "status": "completed",
  "model": "nano-banana-pro",
  "kind": "text",
  "user": "cust-42",
  "settings": {
    "aspect_ratio": "1:1",
    "resolution": "1K"
  },
  "price": {
    "estimated_max": "0.191430",
    "charged": "0.191430",
    "currency": "USD"
  },
  "result": {
    "url": "https://api.puzzle-cloud.com/v1/outputs/2026/09/job_3f9c2b7a1d4e8f60a1b2c3d4.png?exp=1790251209&sig=k2VhO3s9Q6bXw1LpR8dTzN4mY7cFgJ0eAqUiHvKo",
    "mime_type": "image/png",
    "width": 1024,
    "height": 1024,
    "expires_at": "2026-09-24T12:00:09.000Z"
  },
  "error": null,
  "created_at": "2026-09-17T12:00:00.000Z",
  "started_at": "2026-09-17T12:00:02.000Z",
  "completed_at": "2026-09-17T12:00:09.000Z",
  "failed_at": null
}

Errors of this endpoint

StatusCodeWhen
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
404job_not_foundNo job with that id in your workspace (GET or cancel)

Use cases

  • Polling loop after a 202 (see Request flow) — back off between reads; the answer changes at most a few times per job.
  • Fetch the download link after a job.completed webhook — the envelope carries result: null on purpose.

Cancel job

POST/v1/jobs/{id}/cancel

Cancel a job that is still queued. The reservation is released in the same transaction and a job.cancelled event is sent. Once a worker has picked the job up (processing, retry_scheduled) or it has finished, the call answers 409 job_not_cancellable — a provider attempt may already be billable — and the job keeps its current status.

curl -sS -X POST "https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4/cancel" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. The job object with status: "cancelled".

{
  "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
  "request_id": "req_9a1b2c3d4e5f60718293a4b5",
  "status": "cancelled",
  "model": "nano-banana-pro",
  "kind": "text",
  "user": "cust-42",
  "settings": {
    "aspect_ratio": "1:1",
    "resolution": "1K"
  },
  "price": {
    "estimated_max": "0.191430",
    "charged": null,
    "currency": "USD"
  },
  "result": null,
  "error": null,
  "created_at": "2026-09-17T12:00:00.000Z",
  "started_at": null,
  "completed_at": null,
  "failed_at": null
}

Errors of this endpoint

StatusCodeWhen
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
404job_not_foundNo job with that id in your workspace (GET or cancel)
409job_not_cancellableJob is no longer queued (processing or finished); only queued jobs can be cancelled

Use cases

  • Your user abandoned the flow before the worker started — release the reservation immediately.
  • Duplicate submissions detected on your side after the fact (prefer Idempotency-Key to prevent them).

Usage

GET/v1/usage

Aggregates over a range of UTC calendar days (default: the last 30 days, at most 366): jobs by outcome, charged (sum of captured charges of completed jobs, USD, 6 decimals) and images delivered. Add group_by — repeatable or comma-separated — for breakdowns by day, model, status, user or api_key; the ones you do not ask for come back as empty arrays, and days without jobs are omitted from by_day.

ParameterTypeNotes
start_datestringYYYY-MM-DD (UTC), inclusive. Default: 29 days before end_date
end_datestringYYYY-MM-DD (UTC), inclusive. Default: today. Span ≤ 366 days
group_bystringRepeatable or comma-separated: day, model, status, user, api_key. Unselected breakdowns come back empty
curl -sS "https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=day&group_by=model" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. by_status, by_user, by_api_key are empty because they were not requested.

{
  "period": {
    "start": "2026-09-01",
    "end": "2026-09-30",
    "timezone": "UTC"
  },
  "totals": {
    "jobs": 128,
    "completed": 121,
    "failed": 5,
    "cancelled": 2,
    "charged": "23.163030",
    "images": 121,
    "currency": "USD"
  },
  "by_day": [
    {
      "day": "2026-09-01",
      "jobs": 4,
      "completed": 4,
      "failed": 0,
      "cancelled": 0,
      "charged": "0.765720",
      "images": 4
    },
    {
      "day": "2026-09-02",
      "jobs": 7,
      "completed": 6,
      "failed": 1,
      "cancelled": 0,
      "charged": "1.148580",
      "images": 6
    }
  ],
  "by_model": [
    {
      "model": "nano-banana-pro",
      "jobs": 90,
      "completed": 86,
      "failed": 3,
      "cancelled": 1,
      "charged": "16.471980",
      "images": 86
    },
    {
      "model": "nano-banana-v2",
      "jobs": 38,
      "completed": 35,
      "failed": 2,
      "cancelled": 1,
      "charged": "6.691050",
      "images": 35
    }
  ],
  "by_status": [],
  "by_user": [],
  "by_api_key": []
}

Errors of this endpoint

StatusCodeWhen
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key

Use cases

  • Month-end invoice per customer: group_by=user (see Reporting for the full example).
  • Per-integration spend: group_by=api_key when each product or team has its own key.
  • Quality tracking: group_by=model,status to compare failure rates between models.

Balance

GET/v1/balance

Available and reserved balance of the workspace. balance is what new jobs can reserve (USD, 2 decimals); reserved is held by queued or running jobs and returns to balance when they fail or are cancelled; status is credited when balance is above zero, otherwise unsettled. Funding is done in the console (USDC/USDT on Ethereum, Tron or BNB; bank transfer via support) — there is no funding endpoint.

curl -sS "https://api.puzzle-cloud.com/v1/balance" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"

Response 200. A valid key with balance: 0 gets 402 insufficient_funds on generations — expected, not an outage.

{
  "balance": 10,
  "reserved": 0.19,
  "currency": "USD",
  "status": "credited"
}

Errors of this endpoint

StatusCodeWhen
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key

Use cases

  • Pre-flight check before a batch: balance ≥ jobs × estimated_max from POST /v1/quote.
  • Low-balance alert in your own monitoring.

Models

GET/v1/models

Public, no API key. live and coming_soon are plain id lists; models is the catalog — per model the accepted modes (kinds), aspect ratios, resolutions and current prices per image from the active price list (same numbers as GET /v1/pricing). Coming-soon models (omni, veo, gemini) appear with empty parameter sets and answer 422 model_unavailable on generation. Cached for 60 seconds.

curl -sS "https://api.puzzle-cloud.com/v1/models"

Response 200. Cache-Control: public, max-age=60.

{
  "live": [
    "nano-banana-v2",
    "nano-banana-pro"
  ],
  "coming_soon": [
    "omni",
    "veo",
    "gemini"
  ],
  "models": [
    {
      "id": "nano-banana-pro",
      "status": "live",
      "kinds": [
        "text",
        "url"
      ],
      "aspect_ratios": [
        "1:1",
        "16:9",
        "9:16",
        "4:3"
      ],
      "resolutions": [
        "1K",
        "2K",
        "4K"
      ],
      "prices": [
        {
          "kind": "text",
          "resolution": "1K",
          "price_per_image": "0.191430",
          "currency": "USD"
        },
        {
          "kind": "url",
          "resolution": "1K",
          "price_per_image": "0.191430",
          "currency": "USD"
        }
      ]
    },
    {
      "id": "omni",
      "status": "coming_soon",
      "kinds": [],
      "aspect_ratios": [],
      "resolutions": [],
      "prices": []
    }
  ]
}

Use cases

  • Populate a model / resolution picker in your UI without hard-coding ids.
  • Detect when an announced model goes live: it moves from coming_soon to live.

Pricing

GET/v1/pricing

Public, no API key. The active price list: USD per generated image by model, mode and resolution (kind is any when text-to-image and image-to-image cost the same), the effective date, and a plain-language description of reserve → charge. The same numbers as the pricing page. Cached for 15 seconds; 503 pricing_unavailable when no price list is active.

curl -sS "https://api.puzzle-cloud.com/v1/pricing"

Response 200. Prices in the examples are illustrative — the numbers that apply are those of GET /v1/pricing at the time of the request.

{
  "currency": "USD",
  "unit": "image",
  "effective_from": "2026-09-16T00:00:00.000Z",
  "models": [
    {
      "model": "nano-banana-pro",
      "prices": [
        {
          "kind": "any",
          "resolution": "1K",
          "price_per_image": "0.191430",
          "effective_from": "2026-09-16T00:00:00.000Z"
        },
        {
          "kind": "any",
          "resolution": "2K",
          "price_per_image": "0.191430",
          "effective_from": "2026-09-16T00:00:00.000Z"
        },
        {
          "kind": "any",
          "resolution": "4K",
          "price_per_image": "0.342860",
          "effective_from": "2026-09-16T00:00:00.000Z"
        }
      ]
    }
  ],
  "billing": {
    "reservation": "When a job is accepted, its worst-case price is moved from available to reserved balance.",
    "charge": "On completion the price for the delivered resolution is charged and any remainder is released; failed or cancelled jobs release the full reservation."
  },
  "uploads": {
    "enabled": true,
    "price_per_upload": "0.000000",
    "storage_price_per_gb_day": "0.000000",
    "currency": "USD",
    "max_file_bytes": 20971520,
    "default_ttl_hours": 24,
    "max_ttl_hours": 168
  }
}

Errors of this endpoint

StatusCodeWhen
503pricing_unavailableNo active price list, or no active price for this model and resolution

Use cases

  • Render your own price table or margin calculator from live data instead of copying numbers.
  • Agents and assistants: read prices here rather than quoting them from memory.

Download output

GET/v1/outputs/{key}

The download link of a completed job (result.url) points here: https://api.puzzle-cloud.com/v1/outputs/<key>?exp=<unix seconds>&sig=<hmac>. No API key — the signature over key + expiry is the credential, so you may hand the link to your own users; it cannot be forged or extended. HEAD returns the same headers without the body. The image is served with its Content-Type, Content-Length, Content-Disposition: inline and Cache-Control: private, max-age=3600; when the object lives in a bucket the response is a 302 to a short-lived presigned URL.

Links live exactly as long as the object — 7 days after completion on the current storage. After result.expires_at the link answers 410 output_expired and the object is purged; the job keeps its metadata.

# result.url as returned by GET /v1/jobs/{id} — no API key needed
curl -sS -o image.png "https://api.puzzle-cloud.com/v1/outputs/2026/09/job_3f9c2b7a1d4e8f60a1b2c3d4.png?exp=1790251209&sig=k2VhO3s9Q6bXw1LpR8dTzN4mY7cFgJ0eAqUiHvKo"
# headers only
curl -sSI "https://api.puzzle-cloud.com/v1/outputs/2026/09/job_3f9c2b7a1d4e8f60a1b2c3d4.png?exp=1790251209&sig=k2VhO3s9Q6bXw1LpR8dTzN4mY7cFgJ0eAqUiHvKo"

Response 200. Binary image body (image/png, image/jpeg or image/webp).

Errors of this endpoint

StatusCodeWhen
403output_forbiddenDownload link signature is missing or invalid
404output_not_foundOutput no longer stored (retention ended) or unknown key
410output_expiredDownload link expired (past result.expires_at)

Use cases

  • Let the browser fetch the image directly — no proxying through your server, no key exposure.
  • Copy the object to your own storage right after job.completed so it outlives the retention window.

Jobs & results

Poll after a job is accepted (202). Jobs are created only after the price was reserved, so with a $0.00 balance nothing is accepted and an unknown id returns 404 job_not_found. Every key of your workspace sees the same jobs; another workspace's job is 404 too.

Statuses and transitions

StatusCan move toMeaning / Billing
queuedprocessing, cancelledAccepted; waiting for a worker. The only status that can be cancelled. worst-case price reserved
processingretry_scheduled, completed, failedA worker holds the job and is calling the provider. reserved
retry_scheduledprocessing, failedTransient provider failure; Puzzle retries automatically (up to 4 attempts, exponential backoff). reserved
completed— (terminal)Image stored; result carries the signed download link. price.charged captured, remainder released
failed— (terminal)Attempts exhausted or a non-retryable error; error.code / error.message explain why. reservation released — nothing charged
cancelled— (terminal)Cancelled by you while still queued. reservation released — nothing charged

Price

price.estimated_max is the worst-case price reserved when the job was accepted — the same number POST /v1/quote returns for the same parameters. price.charged is null until completion and on failed or cancelled jobs; on a completed job it is the amount actually captured for the delivered resolution, and any remainder of the reservation is released. Both are USD strings with 6 decimals; currency is always USD.

Result and signed links

result is present only on completed jobs fetched with GET /v1/jobs/{id}; in the list endpoint and in webhooks it is null. url is a signed link on the API host that works without an API key and cannot be forged or extended — the HMAC covers the object key and the expiry. mime_type is one of image/png, image/jpeg, image/webp; width / height are read from the file and may be null. expires_at is when the object is removed — 7 days after completion — and the link is valid for exactly that time. HEAD on the link returns the headers only.

{
  "url": "https://api.puzzle-cloud.com/v1/outputs/2026/09/job_3f9c2b7a1d4e8f60a1b2c3d4.png?exp=1790251209&sig=k2VhO3s9Q6bXw1LpR8dTzN4mY7cFgJ0eAqUiHvKo",
  "mime_type": "image/png",
  "width": 1024,
  "height": 1024,
  "expires_at": "2026-09-24T12:00:09.000Z"
}
StatusCodeWhen
403output_forbiddenDownload link signature is missing or invalid
404job_not_foundNo job with that id in your workspace (GET or cancel)
404output_not_foundOutput no longer stored (retention ended) or unknown key
410output_expiredDownload link expired (past result.expires_at)

Failed jobs

A failed job carries error.code — a stable machine-readable value — and error.message, a customer-safe sentence; raw provider text never leaks. Codes marked as retried were tried up to 4 times before the job failed; the message then ends with "Retries exhausted; you were not charged." The reservation is released in every case.

{
  "id": "job_7d21c0aa9e4b5f3c8d6e1f02",
  "request_id": "req_0b1c2d3e4f5061728394a5b6",
  "status": "failed",
  "model": "nano-banana-pro",
  "kind": "text",
  "user": "cust-42",
  "settings": {
    "aspect_ratio": "1:1",
    "resolution": "1K"
  },
  "price": {
    "estimated_max": "0.191430",
    "charged": null,
    "currency": "USD"
  },
  "result": null,
  "error": {
    "code": "provider_server",
    "message": "The generation backend failed. Retries exhausted; you were not charged."
  },
  "created_at": "2026-09-17T12:00:00.000Z",
  "started_at": "2026-09-17T12:00:02.000Z",
  "completed_at": null,
  "failed_at": "2026-09-17T12:07:41.000Z"
}
CodeRetriedWhen
provider_serveryesThe generation backend answered 5xx
provider_networkyesThe generation backend could not be reached
provider_quotayesProvider quota exhausted on the routed project
provider_rate_limitedyesProvider rate limit hit on the routed project
no_capacityyesNo provider project had free capacity for this model
provider_safety_blockednoThe prompt or source image was rejected by the provider's safety filter
provider_invalid_requestnoThe model could not process the request
provider_authnoThe routed provider credential was rejected
provider_timeout_ambiguousnoThe provider call timed out before a result was received
provider_unknownnoUnclassified provider failure
source_url_unreachablenoImage-to-image: the source image could not be downloaded (also source_url_invalid, source_url_scheme, source_url_private, source_url_unresolvable, source_url_redirects, source_url_too_large)
upload_not_foundnoImage-to-image: the upload_id is gone (deleted, expired or purged) or cannot be read on this host
unsupported_modelnoThe model has no provider mapping
storage_or_internal_errornoThe image was generated but could not be stored, or an internal error occurred
worker_lostnoThe worker holding the job disappeared and attempts were exhausted
provider_ambiguousnoThe worker was lost while a provider call may have completed
executor_crashednoThe worker crashed while running the job

Webhooks

Optional callback_url on create (HTTPS, public address), or the HTTPS URL saved in console → Webhooks. A job that was rejected (402, 400, 429 …) never produces an event — there is no job.

Per job: callback_url receives job.queued, job.completed, job.failed, job.cancelled. Per workspace: the endpoint saved under Webhooks receives job.completed, job.failed for every job. Both are delivered by the same signed POST with a versioned envelope (version 2026-09-01); data.job is exactly the object GET /v1/jobs/{id} returns, with result: null — fetch the job for the download link.

Envelope

job.completed
{
  "id": "evt_7c8d9e0f1a2b3c4d5e6f7081",
  "type": "job.completed",
  "version": "2026-09-01",
  "created_at": "2026-09-17T12:00:09.000Z",
  "data": {
    "job": {
      "id": "job_3f9c2b7a1d4e8f60a1b2c3d4",
      "request_id": "req_9a1b2c3d4e5f60718293a4b5",
      "status": "completed",
      "model": "nano-banana-pro",
      "kind": "text",
      "user": "cust-42",
      "settings": {
        "aspect_ratio": "1:1",
        "resolution": "1K"
      },
      "price": {
        "estimated_max": "0.191430",
        "charged": "0.191430",
        "currency": "USD"
      },
      "result": null,
      "error": null,
      "created_at": "2026-09-17T12:00:00.000Z",
      "started_at": "2026-09-17T12:00:02.000Z",
      "completed_at": "2026-09-17T12:00:09.000Z",
      "failed_at": null
    }
  }
}

Headers

HeaderOnNotes
X-Puzzle-Signaturesigning secret setsha256=<hex> — HMAC-SHA256 with your webhook secret over <X-Puzzle-Timestamp>.<raw body>
X-Puzzle-TimestampalwaysUnix seconds when the attempt was sent; reject deliveries older than your tolerance to stop replays
X-Puzzle-Signature-Legacysigning secret setsha256=<hex> over the raw body only — for receivers written before timestamps; prefer X-Puzzle-Signature
X-Puzzle-Event-Idalwaysevt_… — same across retries of one event; use it to de-duplicate
X-Puzzle-Event-Typealwaysjob.queued, job.completed, job.failed, job.cancelled
X-Puzzle-Event-VersionalwaysEnvelope version, currently 2026-09-01
X-Puzzle-Delivery-Attemptalways1 for the first try, then 2 … 7
User-AgentalwaysPuzzle-Webhooks/1

Verify the signature

Create or rotate the webhook secret (puz_whsec_…) under Webhooks; it is shown once and stored encrypted. Without a secret, deliveries are sent unsigned. Verify X-Puzzle-Signature as HMAC-SHA256 with that secret over <X-Puzzle-Timestamp>.<raw body>, compare in constant time, and reject stale timestamps to stop replays — 5 minutes is a common tolerance. X-Puzzle-Signature-Legacy (HMAC over the body only) exists for receivers written before timestamps; prefer the timestamped one.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPuzzleWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const ts = headers["x-puzzle-timestamp"];
  const sig = headers["x-puzzle-signature"];
  if (!ts || !sig) return false;
  // Replay protection: reject deliveries whose timestamp is outside your tolerance.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSeconds) return false;
  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(sig);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Node http / Express: read the RAW body — the signature covers the exact bytes.
// app.post("/puzzle/webhook", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyPuzzleWebhook(req.body.toString("utf8"), req.headers, process.env.PUZZLE_WEBHOOK_SECRET)) return res.sendStatus(401);
//   const event = JSON.parse(req.body);           // { id, type, version, created_at, data: { job } }
//   if (seen.has(event.id)) return res.sendStatus(200); // de-duplicate on X-Puzzle-Event-Id / event.id
//   seen.add(event.id);
//   if (event.type === "job.completed") queue.fetchResult(event.data.job.id); // result is null in the envelope
//   res.sendStatus(200);                           // any 2xx within 10 s stops retries
// });

Delivery, retries, replay

Answer any 2xx within 10 seconds. Anything else — a non-2xx status, a redirect, a timeout, a connection error — is retried after 30 s, 2 min, 10 min, 1 h, 6 h, 24 h; after the 7th failed attempt the delivery is dead-lettered and shown under Webhooks in the console with its status, attempt count, last HTTP status and latency, where you can replay it (same X-Puzzle-Event-Id, attempt counter continues). Deliveries can arrive out of order or more than once — de-duplicate on X-Puzzle-Event-Id. A rejected request (402, 400, 429 …) never produces an event: there is no job.

Endpoint requirements

  • HTTPS only — callback_url and the console endpoint are rejected otherwise (400 invalid_request on the request; the console form refuses to save)
  • Public address — the hostname must resolve to a public IP; loopback, private and link-local ranges are never contacted
  • Answer any 2xx within 10 seconds; redirects are not followed and count as a failed attempt
  • Read the raw request body before parsing JSON — the signature covers the exact bytes sent
  • Be idempotent — the same X-Puzzle-Event-Id may arrive more than once and out of order

Errors

Every error is JSON: { "error": { "status", "code", "message", "request_id" } } — and only these codes exist:

{
  "error": {
    "status": 402,
    "code": "insufficient_funds",
    "message": "Available balance $0.00 is below this request's price $0.19. Fund your balance in the console (USDC/USDT), or message Telegram @PUZZLE_Support_AI for a wire or contract.",
    "request_id": "req_c4d5e6f708192a3b4c5d6e7f"
  }
}

error.request_id is the same value as the X-Request-Id header — quote either to support. message is written for humans and may change; branch on code, never on the message.

StatusCodeWhen
400invalid_jsonBody is not a JSON object
400invalid_requestMissing or invalid model, prompt, url, upload_id, aspect_ratio, resolution, callback_url, user, max_price, ttl_hours, Idempotency-Key, file/data/url for an upload, or query parameter; also 404 for an unknown /v1 path
401unauthorizedMissing, malformed (not puz_live_…), unknown or revoked API key
402insufficient_fundsAvailable balance is below the worst-case price of this generation or the upload fee — nothing charged
402key_spend_limit_exceededThis request would push the key past its daily / monthly / total spend limit (charged + reserved)
403model_not_allowedThe key's allowed-models list excludes this model
403output_forbiddenDownload link signature is missing or invalid
404job_not_foundNo job with that id in your workspace (GET or cancel)
404upload_not_foundNo live upload with that id in your workspace (deleted, expired, purged, or never existed); also on image-to-image when upload_id is gone
404output_not_foundOutput no longer stored (retention ended) or unknown key
405method_not_allowedWrong HTTP method for the path
409idempotency_conflictIdempotency-Key reused with a different request body
409price_exceeds_maxQuoted price is above the max_price you sent — nothing charged
409job_not_cancellableJob is no longer queued (processing or finished); only queued jobs can be cancelled
409upload_quota_exceededThis upload would exceed the workspace file-count or byte quota — delete some uploads or wait for them to expire
410output_expiredDownload link expired (past result.expires_at)
413upload_too_largeFile exceeds the per-file byte limit (see GET /v1/pricing.uploads.max_file_bytes)
415unsupported_media_typeBytes are not a recognised PNG / JPEG / WebP, or that type is not in the operator allow-list
422model_unavailableOmni / Veo / Gemini — announced, not served yet
429rate_limitedWorkspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers
500internal_errorUnexpected server failure — quote request_id to support
503pricing_unavailableNo active price list, or no active price for this model and resolution
503uploads_disabledPOST /v1/uploads is turned off by the operator; existing uploads stay readable and usable — pass a public url instead

Handling each class

Group the codes by what to do: fix the request (invalid_json, invalid_request, method_not_allowed, idempotency_conflict, upload_too_large, unsupported_media_type, upload_quota_exceeded); fix the key or its policy in the console (unauthorized, model_not_allowed, key_spend_limit_exceeded); money — nothing was charged (insufficient_funds, price_exceeds_max); not available — do not loop (model_unavailable, job_not_found, job_not_cancellable, output_*, upload_not_found, uploads_disabled); retry as instructed (rate_limited with Retry-After; pricing_unavailable and internal_error with backoff, logging request_id).

async function puzzle(path, init = {}) {
  const res = await fetch(`https://api.puzzle-cloud.com${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}`, "Content-Type": "application/json", ...init.headers },
  });
  if (res.status === 204) return null;
  if (res.ok || res.status === 202) return res.json();

  const { error } = await res.json(); // { status, code, message, request_id }
  switch (error.code) {
    // Fix the request — retrying the same body will fail again
    case "invalid_json":
    case "invalid_request":
    case "method_not_allowed":
    case "idempotency_conflict":
      throw new Error(`bad request: ${error.message}`);

    // Credentials or key policy — fix in the console (API keys tab)
    case "unauthorized":
    case "model_not_allowed":
    case "key_spend_limit_exceeded":
      throw new Error(`key problem: ${error.message}`);

    // Money — nothing was charged; fund the balance or raise max_price
    case "insufficient_funds":
    case "price_exceeds_max":
      throw new Error(`billing: ${error.message}`);

    // Not available (yet) — do not retry in a loop
    case "model_unavailable":
    case "job_not_found":
    case "job_not_cancellable":
    case "output_expired":
    case "output_forbidden":
    case "output_not_found":
    case "upload_not_found":
    case "uploads_disabled":
      throw new Error(`${error.code}: ${error.message}`);

    case "upload_too_large":
    case "unsupported_media_type":
    case "upload_quota_exceeded":
      throw new Error(`upload: ${error.message}`);

    // Retryable — wait as instructed, then retry the same request (keep the Idempotency-Key)
    case "rate_limited": {
      const wait = Number(res.headers.get("Retry-After") ?? res.headers.get("RateLimit-Reset") ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      return puzzle(path, init);
    }
    case "pricing_unavailable":
    case "internal_error":
      // Log error.request_id — support needs it — then retry with backoff
      console.error("puzzle", error.code, error.request_id);
      throw new Error(`transient: ${error.message} (request_id ${error.request_id})`);
  }
}

Examples

{
  "error": {
    "status": 429,
    "code": "rate_limited",
    "message": "Workspace rate limit is 60 requests per minute. Retry in 17s.",
    "request_id": "req_d5e6f708192a3b4c5d6e7f80"
  }
}
{
  "error": {
    "status": 409,
    "code": "price_exceeds_max",
    "message": "Quoted price $0.342860 exceeds max_price $0.250000 for nano-banana-pro @ 4K. Raise max_price or pick a lower resolution; nothing was charged.",
    "request_id": "req_e6f708192a3b4c5d6e7f8091"
  }
}

Rate limits & quotas

The rate limit is per workspace — not per key, not per account — and applies to POST /v1/text-generations, POST /v1/url-generations and POST /v1/uploads. Default: 60 requests per 60-second sliding window; Puzzle can set a different limit for your workspace, and RateLimit-Limit always tells you the one in force. Requests rejected before the check (401, 400 bad body) carry no RateLimit-* headers; every response after it — including 402, 403, 409, 503 — does. A 429 rate_limited adds Retry-After: wait that many seconds, then resend the same request with the same Idempotency-Key.

Generation responses carry the IETF RateLimit header fields for your workspace; a 429 adds Retry-After. Read them instead of guessing a backoff.

HeaderOnNotes
X-Request-Idevery error; 202 generations; 201 uploads; 204 delete upload; 200 quote, jobs, jobs/{id}, cancel, usage, uploadsRequest id — also error.request_id in error bodies; quote it to support. On a job it equals request_id of the job
RateLimit-Limitgenerations and POST /v1/uploads: 202/201 and every error after the rate-limit checkRequests allowed per minute for your workspace (IETF RateLimit header fields)
RateLimit-Remaininggenerations and POST /v1/uploads: 202/201 and every error after the rate-limit checkRequests left in the current 60-second window
RateLimit-Resetgenerations and POST /v1/uploads: 202/201 and every error after the rate-limit checkSeconds until a slot frees up
Retry-After429Seconds to wait before retrying
Idempotent-Replayed202 (replay only)true when the response is the stored job of the same Idempotency-Key; absent otherwise
Cache-Control200 models, pricing, outputspublic, max-age=60 on models; public, max-age=15 on pricing; private, max-age=3600 on a downloaded output

Quotas

  • Balance is the quota: a job is accepted only if the available balance covers its worst-case price; otherwise 402 insufficient_funds and nothing is reserved. GET /v1/balance shows available and reserved.
  • Per-key spend limits (daily / monthly / total) and model allowlists are set in the console — see Authentication & keys.
  • Concurrency: a workspace runs a bounded number of jobs at once; extra accepted jobs wait in queued and are worked oldest-first. Nothing to configure on your side.
  • Not rate-limited: GET /v1/uploads, GET /v1/uploads/{id}, DELETE /v1/uploads/{id}, POST /v1/quote, GET /v1/jobs, GET /v1/jobs/{id}, POST /v1/jobs/{id}/cancel, GET /v1/usage, GET /v1/balance, GET /v1/models, GET /v1/pricing, GET /v1/outputs/{key}.
  • CORS: every /v1 response allows any origin and exposes X-Request-Id, Idempotent-Replayed, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After to browsers.

Reporting

Two endpoints cover reporting. GET /v1/usage aggregates jobs, charges and delivered images over UTC days with optional group_by (day, model, status, user, api_key) — one call for a month-end statement. GET /v1/jobs gives the line items behind it, filterable by user, status, model and a created_after / created_before window, cursor-paginated up to 100 per page. Tag every generation with user and both become per-customer reports without bookkeeping on your side.

curl -sS "https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=user,api_key" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"
{
  "period": {
    "start": "2026-09-01",
    "end": "2026-09-30",
    "timezone": "UTC"
  },
  "totals": {
    "jobs": 128,
    "completed": 121,
    "failed": 5,
    "cancelled": 2,
    "charged": "23.163030",
    "images": 121,
    "currency": "USD"
  },
  "by_day": [],
  "by_model": [],
  "by_status": [],
  "by_user": [
    {
      "user": "cust-42",
      "jobs": 61,
      "completed": 59,
      "failed": 1,
      "cancelled": 1,
      "charged": "11.294370",
      "images": 59
    },
    {
      "user": "cust-77",
      "jobs": 52,
      "completed": 48,
      "failed": 3,
      "cancelled": 1,
      "charged": "9.188640",
      "images": 48
    },
    {
      "user": null,
      "jobs": 15,
      "completed": 14,
      "failed": 1,
      "cancelled": 0,
      "charged": "2.680020",
      "images": 14
    }
  ],
  "by_api_key": [
    {
      "api_key_id": "key_1a2b3c4d5e6f708192a3b4c5",
      "api_key_prefix": "puz_live_3f9…",
      "jobs": 128,
      "completed": 121,
      "failed": 5,
      "cancelled": 2,
      "charged": "23.163030",
      "images": 121
    }
  ]
}

Per-customer statement

const BASE = "https://api.puzzle-cloud.com";
const headers = { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` };

// 1. Spend per end-user for September (one call): group_by=user
const usage = await (await fetch(`${BASE}/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=user`, { headers })).json();
for (const row of usage.by_user) {
  console.log(row.user ?? "(no user)", row.images, "images", row.charged, "USD");
}

// 2. Line items for one customer: walk GET /v1/jobs with the cursor
const items = [];
let url = `${BASE}/v1/jobs?user=cust-42&status=completed&created_after=2026-09-01T00:00:00Z&created_before=2026-10-01T00:00:00Z&limit=100`;
while (url) {
  const page = await (await fetch(url, { headers })).json();
  items.push(...page.data.map((j) => ({ id: j.id, at: j.completed_at, model: j.model, resolution: j.settings.resolution, usd: j.price.charged })));
  url = page.has_more ? `${BASE}/v1/jobs?user=cust-42&status=completed&limit=100&cursor=${page.next_cursor}` : null;
}
console.table(items);
  • charged sums captured charges of completed jobs only; failed and cancelled jobs contribute to jobs / failed / cancelled but never to charged.
  • images counts delivered images (one per completed job on the current contract).
  • Days are UTC calendar days of created_at; a job created at 23:59 UTC and completed the next day is counted on its creation day.
  • by_user includes a null row for jobs created without user; by_api_key shows the key id and its puz_live_… prefix so you can tell keys apart without storing them.
  • The console's Run history shows the same jobs; use the API when you need them in your own systems.

Best practices

  • Keep API keys on the server. Give each product, team or environment its own key with a spend limit and an allowlist, so a leaked or runaway key is contained and shows up in GET /v1/usage?group_by=api_key.
  • Send Idempotency-Key on every generation and reuse your own stable id (order, task, message). Retries after timeouts or 429s then cost nothing and create nothing twice.
  • Prefer webhooks over polling for anything beyond a demo: callback_url per job or the console endpoint per workspace. If you poll, back off (2 s → 15 s) — reads are free of the rate limit but a job changes status only a few times.
  • Quote first for user-facing prices (POST /v1/quote), and send max_price with the generation so a price you did not expect is refused, not reserved.
  • Set user to your end-user id on every job. It costs nothing and turns GET /v1/jobs and GET /v1/usage into per-customer meters.
  • Download results before result.expires_at (7 days) and store them yourself; treat result.url as a short-lived handle, not a permanent asset URL.
  • Verify webhook signatures with the timestamped scheme, reject stale timestamps, read the raw body, and de-duplicate on X-Puzzle-Event-Id. Fetch GET /v1/jobs/{id} after job.completed — the envelope never carries the link.
  • Branch on error.code, never on error.message; log request_id from every error and from the X-Request-Id header.
  • Read RateLimit-Remaining and RateLimit-Reset and pace your submissions instead of hammering until 429.
  • Read prices and model lists from GET /v1/pricing and GET /v1/models at runtime — never hard-code them.
  • Reuse upload_id for repeated image-to-image on the same source; DELETE uploads you no longer need so they stop occupying quota and accruing storage fees.

API changelog

Additive changes to the public contract, by release date. The webhook envelope carries its own version (X-Puzzle-Event-Version: 2026-09-01); a breaking change to it would ship under a new version value, never silently.

2026-09-17

  • POST / GET / DELETE /v1/uploads — store a source image and reference it from image-to-image as upload_id; operator kill switch leaves existing files readable
  • Upload quotas (files / bytes / TTL) editable in /admin, with per-workspace overrides; upload_fee per file and daily storage_fee per GiB (prices in GET /v1/pricing.uploads; unpaid storage shortens TTL to 24 h)
  • GET /v1/pricing — public price list (USD per image), same numbers as the pricing page
  • POST /v1/quote and the max_price request field (409 price_exceeds_max)
  • GET /v1/jobs — cursor-paginated job history with status, model, user, created_after / created_before filters
  • GET /v1/usage — jobs, charges and images over UTC days with group_by day, model, status, user, api_key
  • GET /v1/models extended with the catalog (models[]: kinds, aspect ratios, resolutions, prices)
  • user request field — your end-user id, returned on the job, filterable and groupable
  • X-Api-Key accepted as an alternative to Authorization: Bearer
  • error.request_id in every error body, equal to the X-Request-Id header
  • RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset on generation responses, Retry-After on 429; the limit is per workspace
  • Per-key guards in the console: spend limit (daily / monthly / total), allowed-models list, revoke (402 key_spend_limit_exceeded, 403 model_not_allowed, 401 after revoke)
  • S3-compatible output storage option (presigned links); local disk stays the default with 7-day retention
  • Expired result objects are purged; the job keeps its metadata and result.expires_at
  • Webhook signing secrets are envelope-encrypted at rest; outbound webhook and source-image connections are pinned to validated public addresses

2026-09-16

  • Public API host https://api.puzzle-cloud.com; the marketing host mirrors generation, balance, models and job status under /api/v1
  • Double-entry ledger: reserve on accept, capture on completion, release on failure or cancellation; price.estimated_max / price.charged on the job
  • PostgreSQL job queue with statuses queued → processing → completed | failed, retry_scheduled between attempts, POST /v1/jobs/{id}/cancel while queued
  • Google Gemini provider adapter, encrypted credential pool and worker process
  • Signed, expiring download links: GET /v1/outputs/{key}?exp&sig
  • Webhook delivery worker: envelope version 2026-09-01 (X-Puzzle-Event-Version), X-Puzzle-Signature over <timestamp>.<body>, retries 30 s … 24 h, dead-letter and replay in the console

Support & status

  • Telegram @PUZZLE_Support_AI — https://t.me/PUZZLE_Support_AI — integration questions, bank transfers and contracts, higher rate limits, funding confirmations. WeChat and WhatsApp are arranged through the same Telegram.
  • When reporting a problem, include the request_id (or X-Request-Id), the job id and the timestamp; for webhooks the X-Puzzle-Event-Id. All of them are visible in the console under Run history and Webhooks.
  • Status: there is no public status page. Liveness and readiness probes of this site are https://puzzle-cloud.com/health/live and https://puzzle-cloud.com/health/ready ({"status":"ok"} / {"status":"ready"}, 503 when not ready). The public API has no uptime guarantee outside a signed contract.
  • Machine-readable twins of this page: https://puzzle-cloud.com/openapi.json (OpenAPI 3.1), https://puzzle-cloud.com/docs.md, https://puzzle-cloud.com/docs-zh.md, https://puzzle-cloud.com/llms-full.txt.
  • Funding: USDC or USDT on Ethereum, Tron or BNB in the console (log the transaction hash; the balance is credited when the transfer is confirmed). Bank transfer or a contract via Telegram. No card checkout.