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.
| Model | Status | Modes | |
|---|---|---|---|
| nano-banana-v2 | live | text-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-pro | live | text-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 | |
| omni | coming soon | 422 model_unavailable on generation; listed in coming_soon of GET /v1/models with empty parameter sets | |
| veo | coming soon | 422 model_unavailable on generation; listed in coming_soon of GET /v1/models with empty parameter sets | |
| gemini | coming soon | 422 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_exceededwith 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.urlis safe to hand to end users — it needs no key.
4. Required headers
| Header | On | Notes | |
|---|---|---|---|
| Authorization | all key endpoints | Bearer puz_live_… — the canonical way to send the key | |
| X-Api-Key | all key endpoints | Alternative to Authorization for SDKs that only speak x-api-key; same key | |
| Content-Type | POST | application/json on JSON bodies; multipart/form-data on POST /v1/uploads when sending file | |
| Idempotency-Key | POST 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_scheduledand 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-Idon 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
| Endpoint | Auth | Notes | |
|---|---|---|---|
| POST /v1/text-generations | API key | Create a text-to-image job — counts toward the rate limit | |
| POST /v1/url-generations | API key | Create an image-to-image job — counts toward the rate limit | |
| POST /v1/uploads | API key | Store a source image for image-to-image — counts toward the rate limit | |
| GET /v1/uploads | API key | List live uploads of the workspace | |
| GET /v1/uploads/{id} | API key | Get one upload and a fresh signed link | |
| DELETE /v1/uploads/{id} | API key | Delete an upload and its stored object | |
| POST /v1/quote | API key | Price a request without creating a job | |
| GET /v1/jobs | API key | List jobs of the workspace (newest first, cursor-paginated) | |
| GET /v1/jobs/{id} | API key | Get one job (completed jobs carry the download link) | |
| POST /v1/jobs/{id}/cancel | API key | Cancel a queued job (reservation released) | |
| GET /v1/usage | API key | Usage and spend aggregates over UTC days | |
| GET /v1/balance | API key | Available and reserved balance | |
| GET /v1/models | none | Model catalog with parameters and prices | |
| GET /v1/pricing | none | Active price list (USD per image) | |
| GET /v1/outputs/{key} | signed link | Download 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).
| Header | On | Notes | |
|---|---|---|---|
| Authorization | all key endpoints | Bearer puz_live_… — the canonical way to send the key | |
| X-Api-Key | all key endpoints | Alternative to Authorization for SDKs that only speak x-api-key; same key | |
| Content-Type | POST | application/json on JSON bodies; multipart/form-data on POST /v1/uploads when sending file | |
| Idempotency-Key | POST 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
| Status | Code | When | |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 402 | key_spend_limit_exceeded | This request would push the key past its daily / monthly / total spend limit (charged + reserved) | |
| 403 | model_not_allowed | The 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.
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_expiredSubmit, 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, headerIdempotent-Replayed: true, nothing reserved twice; the same key with a different body answers409 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_priceis a USD ceiling for this job. If the quoted price is higher the API answers409 price_exceeds_maxwith 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 ofGET /v1/jobsand agroup_bydimension ofGET /v1/usage, which turns Puzzle into a per-customer meter without any bookkeeping on your side.callback_url(HTTPS, public address) receivesjob.queued,job.completed,job.failedandjob.cancelledfor this job only; the endpoint saved in the console receivesjob.completedandjob.failedfor 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
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.
| Field | Type | Notes | |
|---|---|---|---|
| model | string | req | nano-banana-v2, nano-banana-pro. Unknown id → 400; omni, veo, gemini → 422 model_unavailable |
| prompt | string | req | Generation prompt, ≤ 4000 characters |
| aspect_ratio | string | 1:1, 16:9, 9:16, 4:3 | |
| resolution | string | 1K (default), 2K, 4K — sets the price, see POST /v1/quote | |
| callback_url | string | HTTPS webhook for this job: receives job.queued, job.completed, job.failed, job.cancelled. Must resolve to a public address | |
| user | string | Your end-user id (≤ 128 chars, stored verbatim). Returned on the job, filterable in GET /v1/jobs, a group_by of GET /v1/usage | |
| max_price | string | USD 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_json | Body is not a JSON object | |
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 402 | insufficient_funds | Available balance is below the worst-case price of this generation or the upload fee — nothing charged | |
| 402 | key_spend_limit_exceeded | This request would push the key past its daily / monthly / total spend limit (charged + reserved) | |
| 403 | model_not_allowed | The key's allowed-models list excludes this model | |
| 409 | idempotency_conflict | Idempotency-Key reused with a different request body | |
| 409 | price_exceeds_max | Quoted price is above the max_price you sent — nothing charged | |
| 422 | model_unavailable | Omni / Veo / Gemini — announced, not served yet | |
| 429 | rate_limited | Workspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers | |
| 503 | pricing_unavailable | No active price list, or no active price for this model and resolution |
Use cases
- Product imagery on demand: one job per SKU with
userset to the merchant id, soGET /v1/usage?group_by=userbecomes the invoice. - Bulk generation from a queue: reuse your own order id as
Idempotency-Keyso a crashed worker can resend without paying twice. - Cost-capped features: send
max_priceso an unexpected 4K price is refused with 409 instead of being reserved.
Image to image
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.
| Field | Type | Notes | |
|---|---|---|---|
| model | string | req | Same live models as text to image |
| prompt | string | req | Edit / restyle instruction, ≤ 4000 characters |
| url | string | HTTPS source image. Alias: image_url (url wins when both are sent). Omit when sending upload_id | |
| image_url | string | Same as url | |
| upload_id | string | Id 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_ratio | string | 1:1, 16:9, 9:16, 4:3 | |
| resolution | string | 1K (default), 2K, 4K | |
| callback_url | string | HTTPS webhook for this job: receives job.queued, job.completed, job.failed, job.cancelled. Must resolve to a public address | |
| user | string | Your end-user id (≤ 128 chars, stored verbatim). Returned on the job, filterable in GET /v1/jobs, a group_by of GET /v1/usage | |
| max_price | string | USD 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_json | Body is not a JSON object | |
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 402 | insufficient_funds | Available balance is below the worst-case price of this generation or the upload fee — nothing charged | |
| 402 | key_spend_limit_exceeded | This request would push the key past its daily / monthly / total spend limit (charged + reserved) | |
| 403 | model_not_allowed | The key's allowed-models list excludes this model | |
| 409 | idempotency_conflict | Idempotency-Key reused with a different request body | |
| 409 | price_exceeds_max | Quoted price is above the max_price you sent — nothing charged | |
| 422 | model_unavailable | Omni / Veo / Gemini — announced, not served yet | |
| 429 | rate_limited | Workspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers | |
| 503 | pricing_unavailable | No active price list, or no active price for this model and resolution |
Use cases
- Restyle user uploads: POST /v1/uploads once, then pass
upload_idon 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_ratioorresolutionwithout re-prompting from scratch.
Upload a source image
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.
| Field | Type | Notes | |
|---|---|---|---|
| file | file | Multipart field. PNG, JPEG or WebP; sniffed from magic bytes, Content-Type is never trusted | |
| data | string | JSON alternative: base64-encoded image bytes (data: URLs accepted) | |
| url | string | JSON alternative: public https URL Puzzle fetches (SSRF-pinned). Send one of file / data / url | |
| mime_type | string | Informational with data; the sniffer decides the stored type | |
| ttl_hours | integer | Requested lifetime in hours; omitted → platform default. Capped by the workspace max. Live defaults: GET /v1/pricing.uploads | |
| user | string | Your 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_json | Body is not a JSON object | |
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 402 | insufficient_funds | Available balance is below the worst-case price of this generation or the upload fee — nothing charged | |
| 409 | upload_quota_exceeded | This upload would exceed the workspace file-count or byte quota — delete some uploads or wait for them to expire | |
| 413 | upload_too_large | File exceeds the per-file byte limit (see GET /v1/pricing.uploads.max_file_bytes) | |
| 415 | unsupported_media_type | Bytes are not a recognised PNG / JPEG / WebP, or that type is not in the operator allow-list | |
| 429 | rate_limited | Workspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers | |
| 503 | uploads_disabled | POST /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
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
| Status | Code | When | |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 404 | upload_not_found | No 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
urlto 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
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.
| Field | Type | Notes | |
|---|---|---|---|
| model | string | req | nano-banana-v2, nano-banana-pro |
| kind | string | text (default) or url — text-to-image vs image-to-image | |
| resolution | string | 1K (default), 2K, 4K | |
| aspect_ratio | string | Accepted 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_json | Body is not a JSON object | |
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 422 | model_unavailable | Omni / Veo / Gemini — announced, not served yet | |
| 503 | pricing_unavailable | No 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_versionchanges).
Job history
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.
| Parameter | Type | Notes | |
|---|---|---|---|
| status | string | queued, processing, retry_scheduled, completed, failed, cancelled | |
| model | string | Exact model id | |
| user | string | Jobs created with this user value | |
| created_after | string | ISO 8601 date-time, inclusive | |
| created_before | string | ISO 8601 date-time, exclusive | |
| limit | integer | 1–100, default 20 | |
| cursor | string | Opaque 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key |
Use cases
- Reconcile your database with Puzzle after an outage:
created_afterthe last id you stored, walk the cursor. - Customer statements:
user=<id>&status=completedfor a billing period (see Reporting). - Alerting:
status=failed&created_after=<1 h ago>on a schedule.
Job status
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
| Status | Code | When | |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 404 | job_not_found | No 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.completedwebhook — the envelope carriesresult: nullon purpose.
Cancel job
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
| Status | Code | When | |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 404 | job_not_found | No job with that id in your workspace (GET or cancel) | |
| 409 | job_not_cancellable | Job 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-Keyto prevent them).
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.
| Parameter | Type | Notes | |
|---|---|---|---|
| start_date | string | YYYY-MM-DD (UTC), inclusive. Default: 29 days before end_date | |
| end_date | string | YYYY-MM-DD (UTC), inclusive. Default: today. Span ≤ 366 days | |
| group_by | string | Repeatable 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
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, 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_keywhen each product or team has its own key. - Quality tracking:
group_by=model,statusto compare failure rates between models.
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
| Status | Code | When | |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key |
Use cases
- Pre-flight check before a batch:
balance≥ jobs ×estimated_maxfromPOST /v1/quote. - Low-balance alert in your own monitoring.
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_soontolive.
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
| Status | Code | When | |
|---|---|---|---|
| 503 | pricing_unavailable | No 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
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
| Status | Code | When | |
|---|---|---|---|
| 403 | output_forbidden | Download link signature is missing or invalid | |
| 404 | output_not_found | Output no longer stored (retention ended) or unknown key | |
| 410 | output_expired | Download 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.completedso 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
| Status | Can move to | Meaning / Billing | |
|---|---|---|---|
| queued | processing, cancelled | Accepted; waiting for a worker. The only status that can be cancelled. worst-case price reserved | |
| processing | retry_scheduled, completed, failed | A worker holds the job and is calling the provider. reserved | |
| retry_scheduled | processing, failed | Transient 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"
}| Status | Code | When | |
|---|---|---|---|
| 403 | output_forbidden | Download link signature is missing or invalid | |
| 404 | job_not_found | No job with that id in your workspace (GET or cancel) | |
| 404 | output_not_found | Output no longer stored (retention ended) or unknown key | |
| 410 | output_expired | Download 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"
}| Code | Retried | When | |
|---|---|---|---|
| provider_server | yes | The generation backend answered 5xx | |
| provider_network | yes | The generation backend could not be reached | |
| provider_quota | yes | Provider quota exhausted on the routed project | |
| provider_rate_limited | yes | Provider rate limit hit on the routed project | |
| no_capacity | yes | No provider project had free capacity for this model | |
| provider_safety_blocked | no | The prompt or source image was rejected by the provider's safety filter | |
| provider_invalid_request | no | The model could not process the request | |
| provider_auth | no | The routed provider credential was rejected | |
| provider_timeout_ambiguous | no | The provider call timed out before a result was received | |
| provider_unknown | no | Unclassified provider failure | |
| source_url_unreachable | no | Image-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_found | no | Image-to-image: the upload_id is gone (deleted, expired or purged) or cannot be read on this host | |
| unsupported_model | no | The model has no provider mapping | |
| storage_or_internal_error | no | The image was generated but could not be stored, or an internal error occurred | |
| worker_lost | no | The worker holding the job disappeared and attempts were exhausted | |
| provider_ambiguous | no | The worker was lost while a provider call may have completed | |
| executor_crashed | no | The 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
{
"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
| Header | On | Notes | |
|---|---|---|---|
| X-Puzzle-Signature | signing secret set | sha256=<hex> — HMAC-SHA256 with your webhook secret over <X-Puzzle-Timestamp>.<raw body> | |
| X-Puzzle-Timestamp | always | Unix seconds when the attempt was sent; reject deliveries older than your tolerance to stop replays | |
| X-Puzzle-Signature-Legacy | signing secret set | sha256=<hex> over the raw body only — for receivers written before timestamps; prefer X-Puzzle-Signature | |
| X-Puzzle-Event-Id | always | evt_… — same across retries of one event; use it to de-duplicate | |
| X-Puzzle-Event-Type | always | job.queued, job.completed, job.failed, job.cancelled | |
| X-Puzzle-Event-Version | always | Envelope version, currently 2026-09-01 | |
| X-Puzzle-Delivery-Attempt | always | 1 for the first try, then 2 … 7 | |
| User-Agent | always | Puzzle-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_urland the console endpoint are rejected otherwise (400invalid_requeston 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-Idmay 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.
| Status | Code | When | |
|---|---|---|---|
| 400 | invalid_json | Body is not a JSON object | |
| 400 | invalid_request | Missing 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 | |
| 401 | unauthorized | Missing, malformed (not puz_live_…), unknown or revoked API key | |
| 402 | insufficient_funds | Available balance is below the worst-case price of this generation or the upload fee — nothing charged | |
| 402 | key_spend_limit_exceeded | This request would push the key past its daily / monthly / total spend limit (charged + reserved) | |
| 403 | model_not_allowed | The key's allowed-models list excludes this model | |
| 403 | output_forbidden | Download link signature is missing or invalid | |
| 404 | job_not_found | No job with that id in your workspace (GET or cancel) | |
| 404 | upload_not_found | No live upload with that id in your workspace (deleted, expired, purged, or never existed); also on image-to-image when upload_id is gone | |
| 404 | output_not_found | Output no longer stored (retention ended) or unknown key | |
| 405 | method_not_allowed | Wrong HTTP method for the path | |
| 409 | idempotency_conflict | Idempotency-Key reused with a different request body | |
| 409 | price_exceeds_max | Quoted price is above the max_price you sent — nothing charged | |
| 409 | job_not_cancellable | Job is no longer queued (processing or finished); only queued jobs can be cancelled | |
| 409 | upload_quota_exceeded | This upload would exceed the workspace file-count or byte quota — delete some uploads or wait for them to expire | |
| 410 | output_expired | Download link expired (past result.expires_at) | |
| 413 | upload_too_large | File exceeds the per-file byte limit (see GET /v1/pricing.uploads.max_file_bytes) | |
| 415 | unsupported_media_type | Bytes are not a recognised PNG / JPEG / WebP, or that type is not in the operator allow-list | |
| 422 | model_unavailable | Omni / Veo / Gemini — announced, not served yet | |
| 429 | rate_limited | Workspace requests-per-minute limit reached on a generation endpoint or POST /v1/uploads — see RateLimit-* and Retry-After headers | |
| 500 | internal_error | Unexpected server failure — quote request_id to support | |
| 503 | pricing_unavailable | No active price list, or no active price for this model and resolution | |
| 503 | uploads_disabled | POST /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.
| Header | On | Notes | |
|---|---|---|---|
| X-Request-Id | every error; 202 generations; 201 uploads; 204 delete upload; 200 quote, jobs, jobs/{id}, cancel, usage, uploads | Request id — also error.request_id in error bodies; quote it to support. On a job it equals request_id of the job | |
| RateLimit-Limit | generations and POST /v1/uploads: 202/201 and every error after the rate-limit check | Requests allowed per minute for your workspace (IETF RateLimit header fields) | |
| RateLimit-Remaining | generations and POST /v1/uploads: 202/201 and every error after the rate-limit check | Requests left in the current 60-second window | |
| RateLimit-Reset | generations and POST /v1/uploads: 202/201 and every error after the rate-limit check | Seconds until a slot frees up | |
| Retry-After | 429 | Seconds to wait before retrying | |
| Idempotent-Replayed | 202 (replay only) | true when the response is the stored job of the same Idempotency-Key; absent otherwise | |
| Cache-Control | 200 models, pricing, outputs | public, 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_fundsand nothing is reserved.GET /v1/balanceshows 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
queuedand 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
/v1response allows any origin and exposesX-Request-Id,Idempotent-Replayed,RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,Retry-Afterto 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);chargedsums captured charges of completed jobs only; failed and cancelled jobs contribute tojobs/failed/cancelledbut never tocharged.imagescounts 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_userincludes anullrow for jobs created withoutuser;by_api_keyshows the key id and itspuz_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-Keyon 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_urlper 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 sendmax_pricewith the generation so a price you did not expect is refused, not reserved. - Set
userto your end-user id on every job. It costs nothing and turnsGET /v1/jobsandGET /v1/usageinto per-customer meters. - Download results before
result.expires_at(7 days) and store them yourself; treatresult.urlas 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. FetchGET /v1/jobs/{id}afterjob.completed— the envelope never carries the link. - Branch on
error.code, never onerror.message; logrequest_idfrom every error and from theX-Request-Idheader. - Read
RateLimit-RemainingandRateLimit-Resetand pace your submissions instead of hammering until 429. - Read prices and model lists from
GET /v1/pricingandGET /v1/modelsat runtime — never hard-code them. - Reuse
upload_idfor repeated image-to-image on the same source;DELETEuploads 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 asupload_id; operator kill switch leaves existing files readable- Upload quotas (files / bytes / TTL) editable in /admin, with per-workspace overrides;
upload_feeper file and dailystorage_feeper GiB (prices inGET /v1/pricing.uploads; unpaid storage shortens TTL to 24 h) GET /v1/pricing— public price list (USD per image), same numbers as the pricing pagePOST /v1/quoteand themax_pricerequest field (409price_exceeds_max)GET /v1/jobs— cursor-paginated job history withstatus,model,user,created_after/created_beforefiltersGET /v1/usage— jobs, charges and images over UTC days withgroup_byday, model, status, user, api_keyGET /v1/modelsextended with the catalog (models[]: kinds, aspect ratios, resolutions, prices)userrequest field — your end-user id, returned on the job, filterable and groupableX-Api-Keyaccepted as an alternative toAuthorization: Bearererror.request_idin every error body, equal to theX-Request-IdheaderRateLimit-Limit/RateLimit-Remaining/RateLimit-Reseton generation responses,Retry-Afteron 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, 403model_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.chargedon the job - PostgreSQL job queue with statuses queued → processing → completed | failed,
retry_scheduledbetween attempts,POST /v1/jobs/{id}/cancelwhile 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-Signatureover<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(orX-Request-Id), the job id and the timestamp; for webhooks theX-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/liveandhttps://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.