# Puzzle API reference

> Host: `https://api.puzzle-cloud.com`  Auth: `Authorization: Bearer puz_live_…`  Machine-readable: https://puzzle-cloud.com/openapi.json  Human-readable: https://puzzle-cloud.com/docs

Contents: 1. Getting started (important) · 2. Authentication & keys · 3. Request flow · 4. Endpoints · 5. Jobs & results · 6. Webhooks · 7. Errors · 8. Rate limits & quotas · 9. Reporting · 10. Best practices · 11. API changelog · 12. Support & status

---

## 1. Getting started (important)

Puzzle is an asynchronous image-generation API: you create a **job**, its worst-case price is reserved from your workspace balance, a worker runs it on Google Gemini, and you fetch the result by polling or receive it by webhook. A valid key with no credit returns `402 insufficient_funds` — expected, not a broken endpoint; nothing is charged until a job completes.

### 1.1 Models and Playground

| 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, 2K, 4K (1K default) |
| `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, 2K, 4K (1K default) |
| `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 ids are Puzzle model ids, not consumer apps; they will use the same request envelope. Any other id → `400 invalid_request`. Try any live model without code in the console Playground (https://puzzle-cloud.com/dashboard?tab=playground) — it submits through the same domain step as the API. `GET /v1/models` returns the catalog with parameters and prices.

### 1.2 Where to see prices

USD per generated image, by model, mode and resolution — always the same numbers in three places: https://puzzle-cloud.com/pricing, 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 `GET /v1/pricing` object; `0.000000` means free. Never copy prices into code — read them. Prices in the examples are illustrative — the numbers that apply are those of `GET /v1/pricing` at the time of the request.

### 1.3 Create and protect your key

Keys are created in the console under API keys (https://puzzle-cloud.com/dashboard?tab=apikeys). A key looks like `puz_live_…`, is shown once and stored only as a SHA-256 hash — copy it when it appears; if lost, revoke 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), counting charged plus currently reserved amounts of the key's jobs; a request that would cross it → `402 key_spend_limit_exceeded` (message states the limit, the amount used and the refused price).
- **Allowed models** — an allowlist of model ids; empty = every live model; another model → `403 model_not_allowed`.
- **Revoke** — immediate; later requests → `401 unauthorized`.
- **Last used** — shown per key together with the number of jobs it created.
- Keep keys on your server; the signed `result.url` is safe to hand to end users (no key needed).

### 1.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 |

### 1.5 Logs and job history

Every accepted job is visible in the console under Run history (https://puzzle-cloud.com/dashboard?tab=history) — status, price, timestamps, provider attempts — and through `GET /v1/jobs` / `GET /v1/jobs/{id}`. Every 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 — support needs it. Webhook deliveries (status, attempt count, last HTTP status, latency, next attempt) are under Webhooks (https://puzzle-cloud.com/dashboard?tab=webhooks).

### 1.6 Data retention

Generated images are kept **7 days** after completion on the current storage; `result.expires_at` is the exact deadline and the signed link is valid for exactly that time. Afterwards the object is purged, the link answers `410 output_expired`, and the job keeps its metadata. 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` (operator-configurable, default 24 h) or until you `DELETE` it. Puzzle does not use prompts or outputs to train models.

### 1.7 Asynchronous model

A generation answers `202` with a job in `queued` — accepted and reserved, not finished. Statuses: `queued`, `processing`, `retry_scheduled`, `completed`, `failed`, `cancelled`. Get the outcome 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`.

### 1.8 Rate limits

60 requests per 60-second sliding window per workspace by default (Puzzle can set another limit per workspace). `POST /v1/text-generations`, `POST /v1/url-generations` and `POST /v1/uploads` count; everything else does not. Those responses carry `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset`; `429 rate_limited` adds `Retry-After`. See § 8.

### 1.9 Support

Telegram @PUZZLE_Support_AI — https://t.me/PUZZLE_Support_AI — for integration questions, bank transfers, contracts and higher limits; WeChat and WhatsApp are arranged through the same Telegram. Quote the `request_id` or job id. No e-mail or phone channel is published.

### 1.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.
- 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; there is no public status page. `X-Request-Id` is your handle for incidents.
- A workspace runs a bounded number of jobs concurrently; bursts are worked oldest-first.

### Endpoint overview

| Endpoint | Auth | Counts toward RPM | Purpose |
|---|---|---|---|
| `POST /v1/text-generations` | API key | ✓ | Create a text-to-image job |
| `POST /v1/url-generations` | API key | ✓ | Create an image-to-image job |
| `POST /v1/uploads` | API key | ✓ | Store a source image for image-to-image |
| `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`. The marketing host mirrors `/v1/text-generations`, `/v1/url-generations`, `/v1/balance`, `/v1/models`, `/v1/jobs/{id}` under `/api/v1`. `GET /v1/outputs/{key}` also accepts `HEAD`. Unknown paths → `404 invalid_request`; wrong method → `405 method_not_allowed`; every endpoint answers `OPTIONS` with permissive CORS.

---

## 2. Authentication & keys

Every endpoint marked "API key" needs one. Send it as a Bearer token (canonical) or in `X-Api-Key`. Only a SHA-256 hash is stored; missing, malformed (not `puz_live_…`), unknown or revoked keys → `401 unauthorized`. Public endpoints (models, pricing, output downloads) need no key.

**cURL**

```bash
# 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"
```

**JavaScript**

```js
// Keep the key on your server — never ship it to a browser or a mobile app.
const headers = { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` };
const res = await fetch("https://api.puzzle-cloud.com/v1/balance", { headers });
if (res.status === 401) throw new Error("API key missing, malformed or revoked");
console.log(await res.json()); // { balance, reserved, currency, status }
```

**Python**

```python
import os, requests

# Keep the key on your server — never ship it to a browser or a mobile app.
headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/balance", headers=headers)
if r.status_code == 401:
    raise SystemExit("API key missing, malformed or revoked")
print(r.json())  # {'balance': ..., 'reserved': ..., 'currency': 'USD', 'status': ...}
```

| 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 section**

| HTTP | 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. `402 key_spend_limit_exceeded` and `403 model_not_allowed` are policies you set on the key in the console; nothing is reserved when they fire. A valid key with an empty balance gets `402 insufficient_funds` on generations — expected.

---

## 3. Request flow

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: a request is either fully accepted (202) or rejected with nothing reserved.

```text
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

**cURL**

```bash
# 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)"
```

**JavaScript**

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

const accepted = await fetch(`${BASE}/v1/text-generations`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": "order-8812-cover" },
  body: JSON.stringify({ model: "nano-banana-pro", prompt: "A cyan puzzle piece", user: "cust-42", max_price: "0.25" }),
});
if (accepted.status !== 202) throw new Error(JSON.stringify(await accepted.json()));
let job = await accepted.json();

// Poll with backoff: 2 s, 3 s, 4.5 s … capped at 15 s. Reads do not count toward the rate limit.
for (let delay = 2000; !["completed", "failed", "cancelled"].includes(job.status); delay = Math.min(delay * 1.5, 15000)) {
  await new Promise((r) => setTimeout(r, delay));
  job = await (await fetch(`${BASE}/v1/jobs/${job.id}`, { headers })).json();
}

if (job.status !== "completed") throw new Error(`${job.status}: ${job.error?.code ?? ""} ${job.error?.message ?? ""}`);
const image = await (await fetch(job.result.url)).arrayBuffer(); // valid until job.result.expires_at
console.log("charged", job.price.charged, "USD;", image.byteLength, "bytes");
```

**Python**

```python
import os, time, requests

BASE = "https://api.puzzle-cloud.com"
headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}

accepted = requests.post(
    f"{BASE}/v1/text-generations",
    headers={**headers, "Idempotency-Key": "order-8812-cover"},
    json={"model": "nano-banana-pro", "prompt": "A cyan puzzle piece", "user": "cust-42", "max_price": "0.25"},
)
if accepted.status_code != 202:
    raise SystemExit(accepted.json())
job = accepted.json()

# Poll with backoff: 2 s, 3 s, 4.5 s ... capped at 15 s. Reads do not count toward the rate limit.
delay = 2.0
while job["status"] not in ("completed", "failed", "cancelled"):
    time.sleep(delay)
    delay = min(delay * 1.5, 15.0)
    job = requests.get(f"{BASE}/v1/jobs/{job['id']}", headers=headers).json()

if job["status"] != "completed":
    raise SystemExit(f"{job['status']}: {job['error']}")
image = requests.get(job["result"]["url"]).content  # valid until job["result"]["expires_at"]
print("charged", job["price"]["charged"], "USD;", len(image), "bytes")
```

### Idempotency, max_price, user

- `Idempotency-Key` (≤ 128 chars, unique per workspace): same key + same body → the stored job again (`202`, `Idempotent-Replayed: true`, nothing reserved twice); same key + different body → `409 idempotency_conflict`. Two concurrent requests with the same key resolve the same way.
- `max_price` — USD ceiling for this job; quoted price above it → `409 price_exceeds_max` with both numbers, nothing reserved. Not part of the job, does not affect idempotency hashing.
- `user` (≤ 128 chars) — your end-user id, stored verbatim, returned on the job; filter of `GET /v1/jobs` and `group_by` of `GET /v1/usage`.
- `callback_url` (HTTPS, public address) → `job.queued`, `job.completed`, `job.failed`, `job.cancelled` for this job; the console endpoint → `job.completed`, `job.failed` for every job.

---

## 4. Endpoints

One subsection per path. `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`.

### POST /v1/text-generations — 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.

**Request body (JSON)**

| Field | Type | Required | Notes |
|---|---|---|---|
| `model` | string | ✓ | nano-banana-v2, nano-banana-pro. Unknown id → 400; omni, veo, gemini → 422 model_unavailable |
| `prompt` | string | ✓ | 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**

```bash
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"
  }'
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/text-generations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUZZLE_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-8812-cover",
  },
  body: JSON.stringify({
    model: "nano-banana-pro",
    prompt: "A cyan puzzle piece, studio light",
    aspect_ratio: "1:1",
    resolution: "1K",
    user: "cust-42",
    max_price: "0.25"
  }),
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.puzzle-cloud.com/v1/text-generations",
    headers={"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}", "Idempotency-Key": "order-8812-cover"},
    json={
        "model": "nano-banana-pro",
        "prompt": "A cyan puzzle piece, studio light",
        "aspect_ratio": "1:1",
        "resolution": "1K",
        "user": "cust-42",
        "max_price": "0.25"
    },
)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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 `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.

---

### POST /v1/url-generations — 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.

**Request body (JSON)**

| Field | Type | Required | Notes |
|---|---|---|---|
| `model` | string | ✓ | Same live models as text to image |
| `prompt` | string | ✓ | 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**

```bash
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"
  }'
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/url-generations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUZZLE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    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"
  }),
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.puzzle-cloud.com/v1/url-generations",
    headers={"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"},
    json={
        "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"
    },
)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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_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.

---

### POST /v1/uploads — 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.

**Request (multipart `file`, JSON `data`, or JSON `url`)**

| Field | Type | Required | 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**

```bash
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"
```

**JavaScript**

```js
const form = new FormData();
form.set("file", new Blob([bytes], { type: "image/png" }), "source.png");
form.set("ttl_hours", "24");
const res = await fetch("https://api.puzzle-cloud.com/v1/uploads", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
  body: form,
});
console.log(res.status, await res.json()); // 201 { id, url, mime_type, size_bytes, expires_at, … }
```

**Python**

```python
import os, requests

with open("source.png", "rb") as f:
    r = requests.post(
        "https://api.puzzle-cloud.com/v1/uploads",
        headers={"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"},
        files={"file": ("source.png", f, "image/png")},
        data={"ttl_hours": 24},
    )
print(r.status_code, r.json())  # 201 {id, url, mime_type, size_bytes, expires_at, …}
```

**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.

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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 /v1/uploads/{id} — 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**

```bash
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"
```

**JavaScript**

```js
const headers = { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` };
const res = await fetch("https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f", { headers });
console.log(res.status, await res.json());

await fetch("https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f", { method: "DELETE", headers }); // 204
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f", headers=headers)
print(r.status_code, r.json())
requests.delete("https://api.puzzle-cloud.com/v1/uploads/upl_4a5b6c7d8e9f0a1b2c3d4e5f", headers=headers)  # 204
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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 `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.

---

### POST /v1/quote — 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.

**Request body (JSON)**

| Field | Type | Required | Notes |
|---|---|---|---|
| `model` | string | ✓ | 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**

```bash
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"
  }'
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/quote", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUZZLE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "nano-banana-pro",
    kind: "text",
    resolution: "2K"
  }),
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.puzzle-cloud.com/v1/quote",
    headers={"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"},
    json={
        "model": "nano-banana-pro",
        "kind": "text",
        "resolution": "2K"
    },
)
print(r.status_code, r.json())
```

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

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

**Errors of this endpoint** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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_version` changes).

---

### GET /v1/jobs — 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.

**Query parameters**

| Parameter | Type | Required | 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**

```bash
curl -sS "https://api.puzzle-cloud.com/v1/jobs?status=completed&user=cust-42&limit=50" \
  -H "Authorization: Bearer $PUZZLE_API_KEY"
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/jobs?status=completed&user=cust-42&limit=50", {
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/jobs?status=completed&user=cust-42&limit=50", headers=headers)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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_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.

---

### GET /v1/jobs/{id} — 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**

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

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4", {
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4", headers=headers)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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.completed` webhook — the envelope carries `result: null` on purpose.

---

### POST /v1/jobs/{id}/cancel — 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**

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

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4/cancel", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.post("https://api.puzzle-cloud.com/v1/jobs/job_3f9c2b7a1d4e8f60a1b2c3d4/cancel", headers=headers)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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-Key` to prevent them).

---

### GET /v1/usage — 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`.

**Query parameters**

| Parameter | Type | Required | 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**

```bash
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"
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=day&group_by=model", {
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=day&group_by=model", headers=headers)
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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_key` when each product or team has its own key.
- Quality tracking: `group_by=model,status` to compare failure rates between models.

---

### GET /v1/balance — 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**

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

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/balance", {
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/balance", headers=headers)
print(r.status_code, r.json())
```

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

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

**Errors of this endpoint** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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_max` from `POST /v1/quote`.
- Low-balance alert in your own monitoring.

---

### GET /v1/models — 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**

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

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/models");
console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get("https://api.puzzle-cloud.com/v1/models")
print(r.status_code, r.json())
```

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

```json
{
  "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`.

---

### GET /v1/pricing — 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**

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

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/pricing");
console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get("https://api.puzzle-cloud.com/v1/pricing")
print(r.status_code, r.json())
```

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

```json
{
  "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** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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.

---

### GET /v1/outputs/{key} — 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.

**cURL**

```bash
# 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"
```

**JavaScript**

```js
// job = await (await fetch(`https://api.puzzle-cloud.com/v1/jobs/${id}`, { headers })).json()
const res = await fetch(job.result.url); // signed link, no Authorization header
if (!res.ok) throw new Error(`download failed: ${res.status}`);
await writeFile("image.png", Buffer.from(await res.arrayBuffer()));
```

**Python**

```python
# job = requests.get(f"https://api.puzzle-cloud.com/v1/jobs/{job_id}", headers=headers).json()
r = requests.get(job["result"]["url"])  # signed link, no Authorization header
r.raise_for_status()
open("image.png", "wb").write(r.content)
```

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

**Errors of this endpoint** (plus `405 method_not_allowed` / `500 internal_error` on any path)

| HTTP | 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.completed` so it outlives the retention window.

---


## 5. Jobs & results

Jobs are created only after the price was reserved, so with a `$0.00` balance nothing is accepted and an unknown id → `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` — worst-case price reserved on accept (= `POST /v1/quote`). `price.charged` — `null` until completion and on failed / cancelled jobs; on completion the amount captured for the delivered resolution, remainder released. USD strings, 6 decimals; `currency` always `USD`.

### Result and signed links

`result` is present only on completed jobs fetched with `GET /v1/jobs/{id}` (`null` in the list and in webhooks). `url` is a signed link on the API host, no API key, cannot be forged or extended (HMAC over object key + expiry). `mime_type` ∈ `image/png`, `image/jpeg`, `image/webp`; `width` / `height` may be `null`. `expires_at` = object removal — 7 days after completion; the link is valid exactly that long. `HEAD` returns headers only.

```json
{
  "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"
}
```

| HTTP | 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` (stable, machine-readable) and `error.message` (customer-safe; raw provider text never leaks). Codes marked retried were tried up to 4 times first; the message then ends with "Retries exhausted; you were not charged." The reservation is released in every case.

```json
{
  "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"
}
```

| error.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 |

---

## 6. Webhooks

Two ways to receive events, both delivered by the same signed POST with a versioned envelope (`version` `2026-09-01`):

- **Per job:** `callback_url` (HTTPS, public address) receives `job.queued`, `job.completed`, `job.failed`, `job.cancelled` for that job.
- **Per workspace:** the endpoint saved in console → Webhooks (https://puzzle-cloud.com/dashboard?tab=webhooks) receives `job.completed` and `job.failed` for every job.

A rejected request (402, 400, 429 …) never produces an event — there is no job. `data.job` is exactly the object `GET /v1/jobs/{id}` returns, with `result: null` — fetch the job for the download link.

### Envelope

```json
{
  "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 | Present | 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_…`) in console → Webhooks; it is shown once and stored encrypted. Without one, deliveries are unsigned. Verify `X-Puzzle-Signature` = HMAC-SHA256 with the secret over `<X-Puzzle-Timestamp>.<raw body>`, compare in constant time, reject stale timestamps (replay protection; 5 minutes is a common tolerance). `X-Puzzle-Signature-Legacy` (body only) exists for receivers written before timestamps — prefer the timestamped one.

**Node.js**

```js
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
// });
```

**Python**

```python
import hmac, hashlib, time

def verify_puzzle_webhook(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
    ts = headers.get("X-Puzzle-Timestamp")
    sig = headers.get("X-Puzzle-Signature")
    if not ts or not sig:
        return False
    # Replay protection: reject deliveries whose timestamp is outside your tolerance.
    if abs(time.time() - int(ts)) > tolerance_seconds:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

# Flask example — use request.get_data() (raw bytes), never a re-serialised JSON object.
# @app.post("/puzzle/webhook")
# def puzzle_webhook():
#     if not verify_puzzle_webhook(request.get_data(), request.headers, os.environ["PUZZLE_WEBHOOK_SECRET"]):
#         abort(401)
#     event = request.get_json()                    # {id, type, version, created_at, data: {job}}
#     if event["id"] in seen:                        # de-duplicate on X-Puzzle-Event-Id / event["id"]
#         return "", 200
#     seen.add(event["id"])
#     if event["type"] == "job.completed":
#         fetch_result_later(event["data"]["job"]["id"])   # result is null in the envelope
#     return "", 200                                 # any 2xx within 10 s stops retries
```

### Delivery, retries, replay

Answer any `2xx` within 10 seconds. Anything else — non-2xx, redirect, timeout, 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 in console → Webhooks (status, attempt count, last HTTP status, latency), where it can be **replayed** (same `X-Puzzle-Event-Id`; the attempt counter continues). Deliveries may arrive out of order or more than once — de-duplicate on `X-Puzzle-Event-Id`.

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

---

## 7. Errors

Every error has the same envelope; `error.request_id` equals the `X-Request-Id` response header — quote either to support. `message` is for humans and may change; branch on `code`.

```json
{
  "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"
  }
}
```

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

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` (`Retry-After`), `pricing_unavailable` and `internal_error` (backoff, log `request_id`).

**JavaScript**

```js
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})`);
  }
}
```

**Python**

```python
import os, time, requests

BASE = "https://api.puzzle-cloud.com"
HEADERS = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}

FIX_REQUEST = {"invalid_json", "invalid_request", "method_not_allowed", "idempotency_conflict", "upload_too_large", "unsupported_media_type", "upload_quota_exceeded"}
KEY_POLICY = {"unauthorized", "model_not_allowed", "key_spend_limit_exceeded"}
BILLING = {"insufficient_funds", "price_exceeds_max"}          # nothing was charged
NOT_AVAILABLE = {"model_unavailable", "job_not_found", "job_not_cancellable", "output_expired", "output_forbidden", "output_not_found", "upload_not_found", "uploads_disabled"}
TRANSIENT = {"pricing_unavailable", "internal_error"}


class PuzzleError(Exception):
    def __init__(self, error):
        super().__init__(f"{error['code']}: {error['message']} (request_id {error['request_id']})")
        self.code, self.request_id = error["code"], error["request_id"]


def puzzle(method, path, attempt=0, **kwargs):
    r = requests.request(method, f"{BASE}{path}", headers={**HEADERS, **kwargs.pop("headers", {})}, **kwargs)
    if r.status_code == 204:
        return None
    if r.status_code in (200, 201, 202):
        return r.json()
    error = r.json()["error"]  # {status, code, message, request_id}
    if error["code"] == "rate_limited" and attempt < 5:
        wait = int(r.headers.get("Retry-After") or r.headers.get("RateLimit-Reset") or 5)
        time.sleep(wait)
        return puzzle(method, path, attempt + 1, **kwargs)   # same body, same Idempotency-Key
    if error["code"] in TRANSIENT and attempt < 3:
        time.sleep(2 ** attempt)                              # 1 s, 2 s, 4 s
        return puzzle(method, path, attempt + 1, **kwargs)
    if error["code"] in FIX_REQUEST | KEY_POLICY | BILLING | NOT_AVAILABLE:
        raise PuzzleError(error)                              # do not retry the same request
    raise PuzzleError(error)                                  # quote request_id to support
```

### More examples

```json
{
  "error": {
    "status": 429,
    "code": "rate_limited",
    "message": "Workspace rate limit is 60 requests per minute. Retry in 17s.",
    "request_id": "req_d5e6f708192a3b4c5d6e7f80"
  }
}
```

```json
{
  "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"
  }
}
```

---

## 8. 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 per 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. `429 rate_limited` adds `Retry-After`: wait that many seconds, then resend the same request with the same `Idempotency-Key`.

### Response headers

| 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_funds`, nothing reserved. `GET /v1/balance` shows available and reserved.
- Per-key spend limits (daily / monthly / total) and model allowlists — see § 2.
- Concurrency: a workspace runs a bounded number of jobs at once; extra accepted jobs wait in `queued`, oldest first.
- 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.

---

## 9. 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, 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.

**cURL**

```bash
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"
```

**JavaScript**

```js
const res = await fetch("https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=user,api_key", {
  headers: { Authorization: `Bearer ${process.env.PUZZLE_API_KEY}` },
});
console.log(res.status, await res.json());
```

**Python**

```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}
r = requests.get("https://api.puzzle-cloud.com/v1/usage?start_date=2026-09-01&end_date=2026-09-30&group_by=user,api_key", headers=headers)
print(r.status_code, r.json())
```

```json
{
  "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

**JavaScript**

```js
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);
```

**Python**

```python
import os, requests

BASE = "https://api.puzzle-cloud.com"
headers = {"Authorization": f"Bearer {os.environ['PUZZLE_API_KEY']}"}

# 1. Spend per end-user for September (one call): group_by=user
usage = requests.get(f"{BASE}/v1/usage", headers=headers,
                     params={"start_date": "2026-09-01", "end_date": "2026-09-30", "group_by": "user"}).json()
for row in usage["by_user"]:
    print(row["user"] or "(no user)", row["images"], "images", row["charged"], "USD")

# 2. Line items for one customer: walk GET /v1/jobs with the cursor
params = {"user": "cust-42", "status": "completed", "created_after": "2026-09-01T00:00:00Z",
          "created_before": "2026-10-01T00:00:00Z", "limit": 100}
items = []
while True:
    page = requests.get(f"{BASE}/v1/jobs", headers=headers, params=params).json()
    items += [(j["id"], j["completed_at"], j["model"], j["settings"].get("resolution"), j["price"]["charged"]) for j in page["data"]]
    if not page["has_more"]:
        break
    params["cursor"] = page["next_cursor"]
print(len(items), "completed jobs for cust-42")
```

- `charged` sums captured charges of completed jobs only; failed and cancelled jobs count in `jobs` / `failed` / `cancelled` but never in `charged`.
- `images` counts delivered images (one per completed job on the current contract).
- Days are UTC calendar days of `created_at`.
- `by_user` includes a `null` row for jobs created without `user`; `by_api_key` shows the key id and its `puz_live_…` prefix.

---

## 10. Best practices

- Keep API keys on the server. One key per product / team / environment, each with a spend limit and an allowlist; `GET /v1/usage?group_by=api_key` shows who spends what.
- Send `Idempotency-Key` on every generation, reusing your own stable id. Retries after timeouts or 429s then cost nothing and create nothing twice.
- Prefer webhooks over polling beyond a demo. 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.
- Set `user` on every job — it turns `GET /v1/jobs` and `GET /v1/usage` into per-customer meters.
- Download results before `result.expires_at` (7 days) and store them yourself; `result.url` is a short-lived handle.
- Verify webhook signatures with the timestamped scheme, reject stale timestamps, read the raw body, de-duplicate on `X-Puzzle-Event-Id`, and fetch `GET /v1/jobs/{id}` after `job.completed`.
- Branch on `error.code`, never on `error.message`; log `request_id` from every error.
- Read `RateLimit-Remaining` / `RateLimit-Reset` and pace submissions instead of hammering until 429.
- Read prices and models from `GET /v1/pricing` / `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.

---

## 11. 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 ships under a new version value.

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

---

## 12. 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 are visible in the console (Run history, Webhooks).
- Status: no public status page. Liveness / readiness probes of the site: `https://puzzle-cloud.com/health/live`, `https://puzzle-cloud.com/health/ready` (`{"status":"ok"}` / `{"status":"ready"}`, 503 when not ready). No uptime guarantee outside a signed contract.
- Machine-readable twins: 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 tx hash; credited when confirmed). Bank transfer or contract via Telegram. No card checkout.

---

## FAQ

**How do I get started?**  
Sign up, create a key, POST /v1 with a Bearer token. Keep your prompts — pointing an official Google SDK at our host will not work.

**What does 402 mean?**  
The key is valid and the available balance is below the price of the request. Fund, wait for confirmation, then retry. 402 is expected — not a down endpoint.

**Are my prompts used for training?**  
No. Puzzle does not use your prompts or outputs to train Google models or ours.

**Can I see an API key again?**  
No. Puzzle stores a SHA-256 hash. Copy the key when it is created. If you lose it, revoke it and make a new one.

**How long are images kept?**  
7 days after completion (`result.expires_at`). Download what you need before then.

**Do you guarantee uptime?**  
Only in a signed contract. The rate limit is per workspace (default 60 generation requests per minute) and is returned in `RateLimit-Limit` on every generation response. Telegram https://t.me/PUZZLE_Support_AI for a contract or a higher limit.

**Which models are available?**  
Nano Banana 2 and Nano Banana Pro are live. Omni, Veo, and Gemini return 422 until they ship.

---

*Source: https://puzzle-cloud.com/docs — canonical reference*
