# Puzzle API 参考文档

> 接入地址：`https://api.puzzle-cloud.com`  鉴权：`Authorization: Bearer puz_live_…`  机器可读：https://puzzle-cloud.com/openapi.json  网页版：https://puzzle-cloud.com/docs

目录：1. 快速开始（重要）· 2. 鉴权与密钥 · 3. 请求流程 · 4. 接口 · 5. 任务与结果 · 6. Webhook · 7. 错误码 · 8. 频率上限与配额 · 9. 报表 · 10. 最佳实践 · 11. API 更新日志 · 12. 支持与状态

---

## 1. 快速开始（重要）

Puzzle 是异步的图像生成 API：你创建一个**任务**，其最高价格从工作区余额中预留，worker 在 Google Gemini 上执行，你通过轮询或 Webhook 获取结果。密钥有效但没有额度时返回 `402 insufficient_funds`——这是预期结果，不是接口坏了；任务完成前不会扣费。

### 1.1 模型与 Playground

| 模型 | 状态 | 模式 |
|---|---|---|
| `nano-banana-v2` | 在线 | 文生图（`POST /v1/text-generations`）与图生图（`POST /v1/url-generations`）；宽高比 1:1、16:9、9:16、4:3；分辨率 1K、2K、4K（默认 1K） |
| `nano-banana-pro` | 在线 | 文生图（`POST /v1/text-generations`）与图生图（`POST /v1/url-generations`）；宽高比 1:1、16:9、9:16、4:3；分辨率 1K、2K、4K（默认 1K） |
| `omni` | 即将上线 | 生成时返回 `422 model_unavailable`；在 `GET /v1/models` 的 `coming_soon` 中列出，参数为空 |
| `veo` | 即将上线 | 生成时返回 `422 model_unavailable`；在 `GET /v1/models` 的 `coming_soon` 中列出，参数为空 |
| `gemini` | 即将上线 | 生成时返回 `422 model_unavailable`；在 `GET /v1/models` 的 `coming_soon` 中列出，参数为空 |

即将上线的 id 是 Puzzle 模型 id，不是消费级应用；上线后使用同一请求信封。其他 id → `400 invalid_request`。可在控制台 Playground（https://puzzle-cloud.com/dashboard?tab=playground）不写代码试用任意在线模型——它与 API 走同一条领域路径。`GET /v1/models` 返回含参数与价格的目录。

### 1.2 价格在哪看

每张生成图片的 USD 价格，按模型、模式、分辨率——三处数字始终一致：https://puzzle-cloud.com/pricing、公开的 `GET /v1/pricing`（无需密钥，缓存 60 秒）、以及 `POST /v1/quote`（对一个具体请求定价，返回生成会预留的准确 `estimated_max`）。文件上传费用（每文件、以及已存源图每 GiB-日）在同一 `GET /v1/pricing` 对象的 `uploads` 下；`0.000000` 表示免费。不要把价格写进代码——去读取它。示例中的价格仅供参考，以请求时 `GET /v1/pricing` 为准。

### 1.3 创建并保护你的密钥

密钥在控制台的 API 密钥页创建（https://puzzle-cloud.com/dashboard?tab=apikeys）。密钥形如 `puz_live_…`，仅显示一次，只存储 SHA-256 哈希——出现时立即复制；丢失后撤销并重建。密钥属于**工作区**：余额、任务、频率上限和用量都按工作区计算，同一工作区的所有密钥看到同一批任务。

- **消费上限**——每把密钥的 USD 上限，按 daily / monthly / total 周期（UTC 日、UTC 月、累计），计入该密钥任务已扣费与当前预留金额；会超出时 → `402 key_spend_limit_exceeded`（错误信息给出上限、已用金额和被拒价格）。
- **允许模型**——模型 id 白名单；为空表示全部在线模型；其他模型 → `403 model_not_allowed`。
- **撤销**——立即生效；之后的请求 → `401 unauthorized`。
- **最近使用**——每把密钥显示最近使用时间及创建的任务数。
- 密钥放在你的服务器上；签名的 `result.url` 可安全交给最终用户（无需密钥）。

### 1.4 必需请求头

| 请求头 | 适用 | 说明 |
|---|---|---|
| `Authorization` | 所有需密钥接口 | `Bearer puz_live_…`（推荐） |
| `X-Api-Key` | 所有需密钥接口 | 只支持 x-api-key 的 SDK 可用此头，密钥相同 |
| `Content-Type` | POST | `application/json`，请求体必须是 JSON 对象 |
| `Idempotency-Key` | POST 生成接口 | ≤ 128 字符，工作区内唯一。同 key 同请求体 → 返回已存任务（202，`Idempotent-Replayed: true`）；同 key 不同请求体 → 409 idempotency_conflict |

### 1.5 日志与任务历史

每个已接受的任务都在控制台「运行历史」（https://puzzle-cloud.com/dashboard?tab=history）可见——状态、价格、时间戳、上游尝试——也可通过 `GET /v1/jobs` / `GET /v1/jobs/{id}` 获取。每个响应都带 `X-Request-Id`（`req_…`）；任务上等于其 `request_id`，错误体中同为 `error.request_id`。每次调用都记录它——支持需要它。Webhook 投递（状态、尝试次数、最近 HTTP 状态、延迟、下次尝试时间）在「Webhook」页（https://puzzle-cloud.com/dashboard?tab=webhooks）。

### 1.6 数据保留

生成图片在完成后保留 **7 天**（当前存储）；`result.expires_at` 为准确期限，签名链接有效期与之相同。之后对象被清除，链接返回 `410 output_expired`，任务保留元数据。提示词和源图地址随任务存储。公网 `url` 源图在运行时获取、不存储；`upload_id` 源图保存至其自己的 `expires_at`（可由运营配置，默认 24 小时）或直到你 `DELETE`。Puzzle 不使用提示词或输出训练模型。

### 1.7 异步模型

生成请求返回 `202` 和一个 `queued` 状态的任务——已接受并预留，未完成。状态：`queued`、`processing`、`retry_scheduled`、`completed`、`failed`、`cancelled`。通过带退避的轮询 `GET /v1/jobs/{id}`（查询不计入频率上限）或传 `callback_url` 接收签名的 `job.completed` / `job.failed` Webhook 获取结果。只有通过 `GET /v1/jobs/{id}` 获取的已完成任务带 `result`。

### 1.8 频率上限

默认每工作区每 60 秒滑动窗口 60 次请求（Puzzle 可为工作区单独设置）。`POST /v1/text-generations`、`POST /v1/url-generations` 与 `POST /v1/uploads` 计入；其余不计入。这些响应带 `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset`；`429 rate_limited` 另附 `Retry-After`。见 § 8。

### 1.9 支持

Telegram @PUZZLE_Support_AI——https://t.me/PUZZLE_Support_AI——接入问题、银行转账、合同、更高额度；微信与 WhatsApp 通过同一 Telegram 安排。请提供 `request_id` 或任务 id。未公布邮箱或电话渠道。

### 1.10 诚实的预期

- 生成运行在 Google Gemini 上；Puzzle 通过带熔断的上游项目池路由每次尝试。上游临时故障（5xx、配额、限流、网络、无容量）使任务进入 `retry_scheduled` 并指数退避重试——总计最多 4 次。
- 仍然失败的任务释放预留：失败与取消的任务永不扣费。
- 安全过滤拒绝、源图不可达、请求格式错误不重试；立即失败并给出稳定的 `error.code`。
- 可用性目标与 SLA 只存在于签署的合同中；没有公开状态页。`X-Request-Id` 是你处理事故的凭据。
- 工作区并发任务数有限；突发任务按最早优先顺序处理。

### 接口总览

| 接口 | 鉴权 | 计入 RPM | 用途 |
|---|---|---|---|
| `POST /v1/text-generations` | API 密钥 | ✓ | Create a text-to-image job |
| `POST /v1/url-generations` | API 密钥 | ✓ | Create an image-to-image job |
| `POST /v1/uploads` | API 密钥 | ✓ | Store a source image for image-to-image |
| `GET /v1/uploads` | API 密钥 |  | List live uploads of the workspace |
| `GET /v1/uploads/{id}` | API 密钥 |  | Get one upload and a fresh signed link |
| `DELETE /v1/uploads/{id}` | API 密钥 |  | Delete an upload and its stored object |
| `POST /v1/quote` | API 密钥 |  | Price a request without creating a job |
| `GET /v1/jobs` | API 密钥 |  | List jobs of the workspace (newest first, cursor-paginated) |
| `GET /v1/jobs/{id}` | API 密钥 |  | Get one job (completed jobs carry the download link) |
| `POST /v1/jobs/{id}/cancel` | API 密钥 |  | Cancel a queued job (reservation released) |
| `GET /v1/usage` | API 密钥 |  | Usage and spend aggregates over UTC days |
| `GET /v1/balance` | API 密钥 |  | Available and reserved balance |
| `GET /v1/models` | 无 |  | Model catalog with parameters and prices |
| `GET /v1/pricing` | 无 |  | Active price list (USD per image) |
| `GET /v1/outputs/{key}` | 签名链接 |  | Download a generated image via its signed link |

所有路径都在 `https://api.puzzle-cloud.com`。站点在 `/api/v1` 镜像了 `/v1/text-generations`、`/v1/url-generations`、`/v1/balance`、`/v1/models`、`/v1/jobs/{id}`。`GET /v1/outputs/{key}` 同时接受 `HEAD`。未知路径 → `404 invalid_request`；方法错误 → `405 method_not_allowed`；所有接口对 `OPTIONS` 返回宽松的 CORS。

---

## 2. 鉴权与密钥

标注「API 密钥」的接口均需要密钥。以 Bearer（推荐）或 `X-Api-Key` 头发送。只存储 SHA-256 哈希；缺失、格式错误（非 `puz_live_…`）、未知或已撤销 → `401 unauthorized`。公开接口（模型、价目表、结果下载）无需密钥。

**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': ...}
```

**本节的错误**

| HTTP | code | 场景 |
|---|---|---|
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 402 | `key_spend_limit_exceeded` | 本次请求会超过该密钥的每日 / 每月 / 累计消费上限（已扣费 + 预留） |
| 403 | `model_not_allowed` | 该密钥的允许模型列表不包含此模型 |

`401` 关于密钥本身。`402 key_spend_limit_exceeded` 与 `403 model_not_allowed` 是你在控制台为密钥设置的策略；触发时不预留。密钥有效但余额为空时生成返回 `402 insufficient_funds`——属预期。

---

## 3. 请求流程

询价（可选）→ `POST` → `202` → 轮询或 Webhook → `result` → `expires_at`。接受路径上的一切——鉴权、频率上限、白名单、报价、`max_price`、密钥消费上限、预留、任务写入与 `job.queued` Webhook——在一个数据库事务中完成：请求要么完全接受（202），要么被拒绝且不预留。

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

### 提交、轮询、下载

**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")
```

### 幂等、max_price、user

- `Idempotency-Key`（≤ 128 字符，工作区内唯一）：同 key 同请求体 → 返回已存任务（`202`，`Idempotent-Replayed: true`，不会重复预留）；同 key 不同请求体 → `409 idempotency_conflict`。并发的同 key 请求结果相同。
- `max_price`——本任务的 USD 上限；报价高于它 → `409 price_exceeds_max`（信息含两个数字），不预留。不属于任务内容，不影响幂等哈希。
- `user`（≤ 128 字符）——你方最终用户 id，原样存储，随任务返回；可在 `GET /v1/jobs` 过滤、在 `GET /v1/usage` 分组。
- `callback_url`（HTTPS，公网地址）→ 本任务的 `job.queued`、`job.completed`、`job.failed`、`job.cancelled`；控制台地址 → 所有任务的 `job.completed`、`job.failed`。

---

## 4. 接口

每个路径一个小节。任何路径都可能返回 `405 method_not_allowed` 与 `500 internal_error`，各小节不再重复。标注「API 密钥」的接口接受 `Authorization: Bearer` 或 `X-Api-Key`。

### POST /v1/text-generations — 文生图

创建文生图任务。请求经校验、按生效价目表定价、从可用余额预留最高价格并进入队列——在一个事务中完成。在线模型：nano-banana-v2、nano-banana-pro。

`202` 表示「已接受并排队」，不是「已完成」：响应体为任务对象，带 `X-Request-Id` 与 `RateLimit-*` 响应头，最高价格已预留。请轮询 `GET /v1/jobs/{id}` 或传 `callback_url`。发送 `Idempotency-Key` 可安全重试。

**请求体（JSON）**

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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())
```

**响应 `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
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_json` | 请求体不是合法的 JSON 对象 |
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 402 | `insufficient_funds` | 可用余额低于本次生成的最高价格或上传费用——未扣费 |
| 402 | `key_spend_limit_exceeded` | 本次请求会超过该密钥的每日 / 每月 / 累计消费上限（已扣费 + 预留） |
| 403 | `model_not_allowed` | 该密钥的允许模型列表不包含此模型 |
| 409 | `idempotency_conflict` | 同一 Idempotency-Key 被用于不同的请求体 |
| 409 | `price_exceeds_max` | 报价高于你传入的 max_price——未扣费 |
| 422 | `model_unavailable` | Omni / Veo / Gemini 已公布但尚未上线 |
| 429 | `rate_limited` | 生成接口或 POST /v1/uploads 达到工作区每分钟请求上限——见 RateLimit-* 与 Retry-After 响应头 |
| 503 | `pricing_unavailable` | 没有生效的价目表，或该模型/分辨率没有生效价格 |

**使用场景**

- 按需生成商品图：每个 SKU 一个任务，`user` 设为商户 id，`GET /v1/usage?group_by=user` 即为账单。
- 队列批量生成：用你自己的订单 id 作为 `Idempotency-Key`，崩溃后的 worker 重发不会重复扣费。
- 限价功能：传 `max_price`，意外的 4K 价格会以 409 拒绝而不是被预留。

---

### POST /v1/url-generations — 图生图

编辑或重绘已有图片。字段与行为同文生图，另加源图：公网 HTTPS `url`（别名 `image_url`）或 `POST /v1/uploads` 返回的 `upload_id`（二者只传其一）。公网 URL 由 worker 在运行时下载：须可从公网访问，类型为 image/png、image/jpeg、image/webp、image/gif、image/heic、image/heif，不超过 20 MB；下载 20 秒超时。`upload_id` 从 Puzzle 存储读取，且必须仍属于本工作区。源图无法获取时任务失败（`error.code` 为 `source_url_*` 或 `upload_not_found`）并释放预留。

**请求体（JSON）**

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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())
```

**响应 `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
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_json` | 请求体不是合法的 JSON 对象 |
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 402 | `insufficient_funds` | 可用余额低于本次生成的最高价格或上传费用——未扣费 |
| 402 | `key_spend_limit_exceeded` | 本次请求会超过该密钥的每日 / 每月 / 累计消费上限（已扣费 + 预留） |
| 403 | `model_not_allowed` | 该密钥的允许模型列表不包含此模型 |
| 409 | `idempotency_conflict` | 同一 Idempotency-Key 被用于不同的请求体 |
| 409 | `price_exceeds_max` | 报价高于你传入的 max_price——未扣费 |
| 422 | `model_unavailable` | Omni / Veo / Gemini 已公布但尚未上线 |
| 429 | `rate_limited` | 生成接口或 POST /v1/uploads 达到工作区每分钟请求上限——见 RateLimit-* 与 Retry-After 响应头 |
| 503 | `pricing_unavailable` | 没有生效的价目表，或该模型/分辨率没有生效价格 |

**使用场景**

- 重绘用户上传：先 `POST /v1/uploads`，再对同一 `upload_id` 多次图生图，无需自建 CDN。
- 或把文件放到你的 CDN，传 HTTPS 地址，通过 `callback_url` 接收结果。
- 以不同 `aspect_ratio` 或 `resolution` 生成主图变体，无需重写提示词。

---

### POST /v1/uploads — 上传源图

把源图存一次，图生图用 `upload_id` 引用。可发送 `multipart/form-data` 的 `file`、JSON `{ "data": "<base64>" }`，或 JSON `{ "url": "https://…" }`（Puzzle 用与 Webhook 相同的 SSRF 钉扎传输去拉取）。按魔数嗅探，仅接受 image/png、image/jpeg、image/webp。`201` 返回上传对象，带签名 `url`（与生成结果相同的 `/v1/outputs/{key}?exp&sig`），有效至 `expires_at`。

运营方可关闭**新**上传（`503 uploads_disabled`）；已存文件仍可读可用。每工作区文件数 / 字节 / TTL 上限可在 /admin 编辑（覆盖为空即用全局值）。费用（若有）为受理时的 `upload_fee` 与按 GiB 的每日 `storage_fee`；实时数字见 `GET /v1/pricing.uploads`（`0.000000` 即免费）。未付存储费不会立刻删文件：把所有有效上传的过期时间缩短到最多 24 小时，响应可能带 `storage_warning`。`GET /v1/uploads` 列出有效上传（最新在前）。

**查询参数**

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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, …}
```

**响应 `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"
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_json` | 请求体不是合法的 JSON 对象 |
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 402 | `insufficient_funds` | 可用余额低于本次生成的最高价格或上传费用——未扣费 |
| 409 | `upload_quota_exceeded` | 本次上传会超过工作区的文件数或字节配额——请删除部分上传或等待过期 |
| 413 | `upload_too_large` | 文件超过单文件字节上限（见 GET /v1/pricing.uploads.max_file_bytes） |
| 415 | `unsupported_media_type` | 字节不是可识别的 PNG / JPEG / WebP，或不在运营方允许列表中 |
| 429 | `rate_limited` | 生成接口或 POST /v1/uploads 达到工作区每分钟请求上限——见 RateLimit-* 与 Retry-After 响应头 |
| 503 | `uploads_disabled` | 运营方已关闭 POST /v1/uploads；已有上传仍可读可用——请改传公网 url |

**使用场景**

- 应用内接收用户照片，上传到这里，再对同一 `upload_id` 跑多次图生图。
- 源图只需活过编辑会话时（`ttl_hours`），不必放到你自己的 CDN。

---

### GET /v1/uploads/{id} — 查询或删除上传

获取本工作区一个仍有效的上传。签名 `url` 每次调用新签发，有效至 `expires_at`。已删除、过期或清除的 id 返回 `404 upload_not_found`。`DELETE`（204）软删除并移除对象，不再占用配额。列表与删除不计入频率上限。

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

**响应 `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"
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 404 | `upload_not_found` | 工作区中没有该 id 的有效上传（已删除、过期、清除或不存在）；图生图的 `upload_id` 失效时同样返回 |

**使用场景**

- 把 `url` 交给你的预览界面，无需经你的服务器中转字节。
- 编辑会话一结束就删除，避免继续占用配额并产生存储费。

---

### POST /v1/quote — 询价

发送前先询价。返回该参数组合下一次生成会预留的最高价格（与任务上的 `price.estimated_max` 相同）及价目表版本 id。不创建任务、不预留、不扣费，也不计入频率上限。

若要让 API 拒绝而不是接受意外价格，在生成请求中传 `max_price`：报价高于它时返回 `409 price_exceeds_max`，不扣费。

**请求体（JSON）**

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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())
```

**响应 `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"
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_json` | 请求体不是合法的 JSON 对象 |
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 422 | `model_unavailable` | Omni / Veo / Gemini 已公布但尚未上线 |
| 503 | `pricing_unavailable` | 没有生效的价目表，或该模型/分辨率没有生效价格 |

**使用场景**

- 在用户确认生成前展示准确价格。
- 每晚检查 Puzzle 发布新价目表（`pricing_version` 变化）后你的利润是否仍成立。

---

### GET /v1/jobs — 任务历史

工作区任务，按创建时间倒序，基于 `(created_at, id)` 的游标分页：把 `next_cursor` 作为 `cursor` 传回，直到 `has_more` 为 `false`。同一工作区的所有密钥看到同一批任务。列表不含 `result`（恒为 `null`），下载链接请查询单个任务。

**查询参数**

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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())
```

**响应 `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"
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |

**使用场景**

- 故障后与你的数据库对账：`created_after` 你保存的最后一条，走游标翻页。
- 客户账单：按账期 `user=<id>&status=completed`（见「报表」）。
- 告警：定时查询 `status=failed&created_after=<1 小时前>`。

---

### GET /v1/jobs/{id} — 任务状态

查询单个任务。已完成任务带 `price.charged` 和 `result`（每次调用都新签发的下载链接，有效至 `result.expires_at`）；失败任务带 `error`（`code`、`message`），预留已释放。任务只在价格预留成功后创建，所以你从未拿到的 id 或其他工作区的任务返回 `404 job_not_found`。查询不计入频率上限。

**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())
```

**响应 `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
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 404 | `job_not_found` | 你的工作区中没有该 id 的任务（查询或取消） |

**使用场景**

- 202 之后的轮询循环（见「请求流程」）——请退避；一个任务的状态只会变化几次。
- 收到 `job.completed` Webhook 后获取下载链接——信封中 `result` 故意为 `null`。

---

### POST /v1/jobs/{id}/cancel — 取消任务

取消仍处于 `queued` 的任务：同一事务中释放预留并发送 `job.cancelled` 事件。任务一旦被 worker 领取（`processing`、`retry_scheduled`）或已结束，返回 `409 job_not_cancellable`——上游调用可能已产生费用——任务保持当前状态。

**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())
```

**响应 `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
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 404 | `job_not_found` | 你的工作区中没有该 id 的任务（查询或取消） |
| 409 | `job_not_cancellable` | 任务已不在 queued 状态（处理中或已结束）；只能取消排队中的任务 |

**使用场景**

- 用户在 worker 开始前放弃了流程——立即释放预留。
- 事后发现重复提交（更推荐用 `Idempotency-Key` 预防）。

---

### GET /v1/usage — 用量

按 UTC 日历日范围汇总（默认最近 30 天，最多 366 天）：按结果统计任务数、`charged`（已完成任务实际扣费之和，USD，6 位小数）和交付图片数 `images`。`group_by` 可重复或逗号分隔：`day`、`model`、`status`、`user`、`api_key`；未请求的维度返回空数组，无任务的日期不出现在 `by_day`。

**查询参数**

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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())
```

**响应 `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": []
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |

**使用场景**

- 月末按客户开票：`group_by=user`（完整示例见「报表」）。
- 按集成统计：每个产品或团队各用一把密钥时 `group_by=api_key`。
- 质量跟踪：`group_by=model,status` 比较模型失败率。

---

### GET /v1/balance — 余额

工作区的可用与预留余额。`balance` 为新任务可预留的金额（USD，2 位小数）；`reserved` 为排队 / 运行中任务占用的金额，失败或取消后回到 `balance`；`balance` > 0 时 `status` 为 `credited`，否则 `unsettled`。充值在控制台完成（USDC/USDT，以太坊 / 波场 / BNB；银行转账联系支持）——没有充值接口。

**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())
```

**响应 `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"
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |

**使用场景**

- 批量前预检：`balance` ≥ 任务数 × `POST /v1/quote` 的 `estimated_max`。
- 在你自己的监控中做低余额告警。

---

### GET /v1/models — 模型

公开接口，无需密钥。`live` 与 `coming_soon` 为 id 列表；`models` 为目录——每个模型可用的模式（`kinds`）、宽高比、分辨率及当前每张图价格（与 `GET /v1/pricing` 一致）。即将上线的模型（omni、veo、gemini）参数为空，生成时返回 `422 model_unavailable`。缓存 60 秒。

**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())
```

**响应 `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": []
    }
  ]
}
```


**使用场景**

- 在你的 UI 中填充模型 / 分辨率选择器，无需硬编码 id。
- 检测已公布模型上线：它会从 `coming_soon` 移到 `live`。

---

### GET /v1/pricing — 价目表

公开接口，无需密钥。生效价目表：按模型、模式与分辨率的每张图 USD 价格（文生图与图生图同价时 `kind` 为 `any`）、生效日期，以及预留 → 扣费的文字说明。与价格页数字一致。缓存 60 秒；无生效价目表时返回 `503 pricing_unavailable`。

**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())
```

**响应 `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
  }
}
```

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 503 | `pricing_unavailable` | 没有生效的价目表，或该模型/分辨率没有生效价格 |

**使用场景**

- 用实时数据渲染你自己的价格表或利润计算器，而不是复制数字。
- AI 代理与助手：从这里读价格，不要凭记忆报价。

---

### GET /v1/outputs/{key} — 下载结果

已完成任务的下载链接（`result.url`）指向这里：`https://api.puzzle-cloud.com/v1/outputs/<key>?exp=<unix 秒>&sig=<hmac>`。无需密钥——对 key + 过期时间的签名即凭证，可直接交给你的用户；无法伪造或延长。`HEAD` 返回同样的响应头但无正文。图片带 `Content-Type`、`Content-Length`、`Content-Disposition: inline` 和 `Cache-Control: private, max-age=3600`；对象存于 bucket 时返回 `302` 到短时预签名地址。

链接寿命与对象相同——当前存储为完成后 7 天。超过 `result.expires_at` 后链接返回 `410 output_expired`，对象被清除，任务保留元数据。

**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)
```

**响应 `200`。** Binary image body (`image/png`, `image/jpeg` or `image/webp`).

**本接口的错误**（任何路径还可能返回 `405 method_not_allowed` / `500 internal_error`）

| HTTP | code | 场景 |
|---|---|---|
| 403 | `output_forbidden` | 下载链接签名缺失或无效 |
| 404 | `output_not_found` | 输出已不再保存（保留期结束）或 key 未知 |
| 410 | `output_expired` | 下载链接已过期（超过 result.expires_at） |

**使用场景**

- 让浏览器直接获取图片——不经你的服务器中转，不暴露密钥。
- 收到 `job.completed` 后立即复制到你自己的存储，使其超出保留期仍可用。

---


## 5. 任务与结果

任务只在价格预留成功后创建，所以余额 `$0.00` 时不会受理，未知 id → `404 job_not_found`。同一工作区的所有密钥看到同一批任务；其他工作区的任务同样 404。

### 状态与转换

| 状态 | 可转为 | 含义 | 计费 |
|---|---|---|---|
| `queued` | `processing`、`cancelled` | 已接受，等待 worker。唯一可取消的状态 | 已预留最高价格 |
| `processing` | `retry_scheduled`、`completed`、`failed` | worker 持有任务并调用上游 | 预留中 |
| `retry_scheduled` | `processing`、`failed` | 上游临时故障；Puzzle 自动重试（最多 4 次，指数退避） | 预留中 |
| `completed` | —（终态） | 图片已存储；`result` 带签名下载链接 | 扣取 `price.charged`，余额释放 |
| `failed` | —（终态） | 重试耗尽或不可重试错误；`error.code` / `error.message` 说明原因 | 释放预留——不扣费 |
| `cancelled` | —（终态） | 你在排队时取消 | 释放预留——不扣费 |

`price.estimated_max`——接受时预留的最高价格（= `POST /v1/quote`）。`price.charged`——完成前及失败 / 取消任务为 `null`；完成时为按交付分辨率实际扣取的金额，余额释放。USD 字符串，6 位小数。

`result` 只出现在通过 `GET /v1/jobs/{id}` 获取的已完成任务上（列表与 Webhook 中为 `null`）。`url` 为 API 主机上的签名链接，无需密钥，无法伪造或延长。`mime_type` ∈ `image/png`、`image/jpeg`、`image/webp`；`expires_at` = 对象删除时间——完成后 7 天。

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

失败任务带稳定的 `error.code` 与面向客户的 `error.message`（不泄露上游原文）。标为重试的代码先尝试最多 4 次。预留一律释放。

```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 | 自动重试 | 场景 |
|---|---|---|
| `provider_server` | 是 | The generation backend answered 5xx |
| `provider_network` | 是 | The generation backend could not be reached |
| `provider_quota` | 是 | Provider quota exhausted on the routed project |
| `provider_rate_limited` | 是 | Provider rate limit hit on the routed project |
| `no_capacity` | 是 | No provider project had free capacity for this model |
| `provider_safety_blocked` | 否 | The prompt or source image was rejected by the provider's safety filter |
| `provider_invalid_request` | 否 | The model could not process the request |
| `provider_auth` | 否 | The routed provider credential was rejected |
| `provider_timeout_ambiguous` | 否 | The provider call timed out before a result was received |
| `provider_unknown` | 否 | Unclassified provider failure |
| `source_url_unreachable` | 否 | 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` | 否 | Image-to-image: the `upload_id` is gone (deleted, expired or purged) or cannot be read on this host |
| `unsupported_model` | 否 | The model has no provider mapping |
| `storage_or_internal_error` | 否 | The image was generated but could not be stored, or an internal error occurred |
| `worker_lost` | 否 | The worker holding the job disappeared and attempts were exhausted |
| `provider_ambiguous` | 否 | The worker was lost while a provider call may have completed |
| `executor_crashed` | 否 | The worker crashed while running the job |

---

## 6. Webhook

两种接收事件的方式，投递格式相同（签名 POST，带版本的信封 `version` `2026-09-01`）：

- **按任务：** `callback_url`（HTTPS，公网地址）接收本任务的 `job.queued`、`job.completed`、`job.failed`、`job.cancelled`。
- **按工作区：** 控制台 → Webhook（https://puzzle-cloud.com/dashboard?tab=webhooks）保存的地址接收所有任务的 `job.completed` 与 `job.failed`。

被拒绝的请求（402、400、429 …）不会产生事件——因为没有任务。`data.job` 与 `GET /v1/jobs/{id}` 返回的对象完全一致（`result` 为 `null`，请查询任务获取下载链接）。

### 信封

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

### 请求头

| 请求头 | 出现 | 说明 |
|---|---|---|
| `X-Puzzle-Signature` | 已设置签名密钥 | `sha256=<hex>`——用你的 Webhook 密钥对 `<X-Puzzle-Timestamp>.<原始请求体>` 计算的 HMAC-SHA256 |
| `X-Puzzle-Timestamp` | 总是 | 发送时的 Unix 秒；拒绝超出容忍窗口的投递以防重放 |
| `X-Puzzle-Signature-Legacy` | 已设置签名密钥 | 仅对原始请求体的 `sha256=<hex>`——供旧接收端使用；请优先使用 X-Puzzle-Signature |
| `X-Puzzle-Event-Id` | 总是 | `evt_…`——同一事件的重试保持一致；用于去重 |
| `X-Puzzle-Event-Type` | 总是 | job.queued、job.completed、job.failed、job.cancelled |
| `X-Puzzle-Event-Version` | 总是 | 信封版本，目前为 2026-09-01 |
| `X-Puzzle-Delivery-Attempt` | 总是 | 首次为 1，之后 2 … 7 |
| `User-Agent` | 总是 | `Puzzle-Webhooks/1` |

### 验证签名

在控制台 → Webhook 创建或轮换密钥（`puz_whsec_…`）；仅显示一次，加密存储。没有密钥时投递不带签名。用密钥对 `<X-Puzzle-Timestamp>.<原始请求体>` 计算 HMAC-SHA256，与 `X-Puzzle-Signature` 做常量时间比较，并拒绝过期时间戳（防重放；常用容忍 5 分钟）。`X-Puzzle-Signature-Legacy`（仅对请求体）供旧接收端使用——请优先使用带时间戳的方案。

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

### 投递、重试、重放

请在 10 秒内返回任意 `2xx`。否则（非 2xx、重定向、超时、连接错误）按 30 s、2 min、10 min、1 h、6 h、24 h 的间隔重试；第 7 次失败后进入**死信**，在控制台 → Webhook 显示（状态、尝试次数、最近 HTTP 状态、延迟），可**重放**（同一 `X-Puzzle-Event-Id`，尝试计数继续）。投递可能乱序或重复——请按 `X-Puzzle-Event-Id` 去重。

### 接收端要求

- 仅 HTTPS——`callback_url` 与控制台地址否则被拒绝（请求返回 400 `invalid_request`；控制台表单拒绝保存）
- 公网地址——主机名必须解析到公网 IP；回环、私网与链路本地地址永不连接
- 10 秒内返回任意 2xx；不跟随重定向，重定向视为失败
- 先读取原始请求体再解析 JSON——签名覆盖发送的精确字节
- 幂等——同一 `X-Puzzle-Event-Id` 可能重复或乱序到达

---

## 7. 错误码

所有错误使用同一结构；`error.request_id` 与响应头 `X-Request-Id` 相同，联系支持时任选其一提供。`message` 面向人类阅读、可能变化；请按 `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 | 场景 |
|---|---|---|
| 400 | `invalid_json` | 请求体不是合法的 JSON 对象 |
| 400 | `invalid_request` | model、prompt、url、upload_id、aspect_ratio、resolution、callback_url、user、max_price、ttl_hours、Idempotency-Key、上传的 file/data/url 或查询参数缺失/不合法；未知的 /v1 路径也返回 404 与此代码 |
| 401 | `unauthorized` | API 密钥缺失、格式错误（非 puz_live_…）、未知或已撤销 |
| 402 | `insufficient_funds` | 可用余额低于本次生成的最高价格或上传费用——未扣费 |
| 402 | `key_spend_limit_exceeded` | 本次请求会超过该密钥的每日 / 每月 / 累计消费上限（已扣费 + 预留） |
| 403 | `model_not_allowed` | 该密钥的允许模型列表不包含此模型 |
| 403 | `output_forbidden` | 下载链接签名缺失或无效 |
| 404 | `job_not_found` | 你的工作区中没有该 id 的任务（查询或取消） |
| 404 | `upload_not_found` | 工作区中没有该 id 的有效上传（已删除、过期、清除或不存在）；图生图的 `upload_id` 失效时同样返回 |
| 404 | `output_not_found` | 输出已不再保存（保留期结束）或 key 未知 |
| 405 | `method_not_allowed` | 该路径不支持此 HTTP 方法 |
| 409 | `idempotency_conflict` | 同一 Idempotency-Key 被用于不同的请求体 |
| 409 | `price_exceeds_max` | 报价高于你传入的 max_price——未扣费 |
| 409 | `job_not_cancellable` | 任务已不在 queued 状态（处理中或已结束）；只能取消排队中的任务 |
| 409 | `upload_quota_exceeded` | 本次上传会超过工作区的文件数或字节配额——请删除部分上传或等待过期 |
| 410 | `output_expired` | 下载链接已过期（超过 result.expires_at） |
| 413 | `upload_too_large` | 文件超过单文件字节上限（见 GET /v1/pricing.uploads.max_file_bytes） |
| 415 | `unsupported_media_type` | 字节不是可识别的 PNG / JPEG / WebP，或不在运营方允许列表中 |
| 422 | `model_unavailable` | Omni / Veo / Gemini 已公布但尚未上线 |
| 429 | `rate_limited` | 生成接口或 POST /v1/uploads 达到工作区每分钟请求上限——见 RateLimit-* 与 Retry-After 响应头 |
| 500 | `internal_error` | 服务器异常——联系支持时请提供 request_id |
| 503 | `pricing_unavailable` | 没有生效的价目表，或该模型/分辨率没有生效价格 |
| 503 | `uploads_disabled` | 运营方已关闭 POST /v1/uploads；已有上传仍可读可用——请改传公网 url |

### 分类处理

修正请求：`invalid_json`、`invalid_request`、`method_not_allowed`、`idempotency_conflict`、`upload_too_large`、`unsupported_media_type`、`upload_quota_exceeded`。在控制台修正密钥或其策略：`unauthorized`、`model_not_allowed`、`key_spend_limit_exceeded`。资金——未扣费：`insufficient_funds`、`price_exceeds_max`。不可用——不要循环：`model_unavailable`、`job_not_found`、`job_not_cancellable`、`output_*`、`upload_not_found`、`uploads_disabled`。按指示重试：`rate_limited`（`Retry-After`）、`pricing_unavailable` 与 `internal_error`（退避并记录 `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
```

```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. 频率上限与配额

频率上限**按工作区**计算——不按密钥、不按账户——作用于 `POST /v1/text-generations`、`POST /v1/url-generations` 与 `POST /v1/uploads`。默认：**每 60 秒滑动窗口 60 次**；Puzzle 可为工作区单独设置，`RateLimit-Limit` 总是告诉你当前生效的上限。校验前被拒绝的请求（`401`、`400` 请求体错误）不带 `RateLimit-*` 头；之后的每个响应（含 402、403、409、503）都带。`429 rate_limited` 另附 `Retry-After`：等待该秒数后用同一 `Idempotency-Key` 重发同一请求。

| 响应头 | 出现于 | 说明 |
|---|---|---|
| `X-Request-Id` | 所有错误；生成接口 202；quote、jobs、jobs/{id}、cancel、usage 的 200 | 请求 id——错误体中同为 `error.request_id`；联系支持时提供。任务上等于 `request_id` |
| `RateLimit-Limit` | 生成接口：202 及频率校验后的所有错误 | 工作区每分钟允许的请求数（IETF RateLimit 头） |
| `RateLimit-Remaining` | 同上 | 当前 60 秒窗口剩余请求数 |
| `RateLimit-Reset` | 同上 | 距释放名额的秒数 |
| `Retry-After` | 429 | 重试前需等待的秒数 |
| `Idempotent-Replayed` | 202（仅重放时） | 响应为同一 Idempotency-Key 已存任务时为 `true`；否则不出现 |
| `Cache-Control` | models、pricing、outputs 的 200 | models 与 pricing 为 `public, max-age=60`；下载输出为 `private, max-age=3600` |

配额：余额即配额（可用余额需覆盖最高价格，否则 `402 insufficient_funds`）；每把密钥的消费上限（daily / monthly / total）与模型白名单见 § 2；工作区并发任务数有限，多余任务在 `queued` 等待。不计入频率上限：`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：所有 `/v1` 响应允许任意来源，并向浏览器暴露 `X-Request-Id`、`Idempotent-Replayed`、`RateLimit-Limit`、`RateLimit-Remaining`、`RateLimit-Reset`、`Retry-After`。

---

## 9. 报表

`GET /v1/usage` 按 UTC 日期汇总任务、扣费与交付图片数，可 `group_by`（`day`、`model`、`status`、`user`、`api_key`）——一次调用即月末账单。`GET /v1/jobs` 给出明细，可按 `user`、`status`、`model` 和 `created_after` / `created_before` 过滤，游标分页每页最多 100 条。每次生成都带 `user`，两者即成为按客户的报表。

**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
    }
  ]
}
```

**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` 只汇总已完成任务的实际扣费；失败与取消的任务计入 `jobs` / `failed` / `cancelled`，不计入 `charged`。
- `images` 为交付图片数（当前每个已完成任务一张）。
- 日期为 `created_at` 的 UTC 日历日。
- `by_user` 含一行 `null`（未传 `user` 的任务）；`by_api_key` 显示密钥 id 及其 `puz_live_…` 前缀。

---

## 10. 最佳实践

- 密钥放在服务器上。每个产品 / 团队 / 环境一把密钥，各设消费上限与白名单；`GET /v1/usage?group_by=api_key` 显示各自消费。
- 每次生成都发 `Idempotency-Key`，复用你自己的稳定 id。超时或 429 后重试不花钱、不重复创建。
- 演示之外优先用 Webhook 而非轮询。若轮询请退避（2 秒 → 15 秒）。
- 面向用户的价格先 `POST /v1/quote`，生成时带 `max_price`。
- 每个任务都设 `user`——让 `GET /v1/jobs` 与 `GET /v1/usage` 成为按客户的计量器。
- 在 `result.expires_at`（7 天）之前下载结果并自行存储；`result.url` 是短期句柄。
- 用带时间戳的方案验证 Webhook 签名，拒绝过期时间戳，读取原始请求体，按 `X-Puzzle-Event-Id` 去重，收到 `job.completed` 后查询 `GET /v1/jobs/{id}`。
- 按 `error.code` 分支，不要按 `error.message`；记录每个错误的 `request_id`。
- 读取 `RateLimit-Remaining` / `RateLimit-Reset` 控制节奏，不要打到 429。
- 运行时从 `GET /v1/pricing` / `GET /v1/models` 读取价格与模型——不要硬编码。
- 对同一源图的多次图生图复用 `upload_id`；不再需要时 `DELETE`，以免继续占用配额并产生存储费。

---

## 11. API 更新日志

公开契约的增量变更，按发布日期。Webhook 信封有自己的版本（`X-Puzzle-Event-Version: 2026-09-01`）；破坏性变更会以新版本值发布。

### 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. 支持与状态

- Telegram @PUZZLE_Support_AI——https://t.me/PUZZLE_Support_AI——接入问题、银行转账与合同、更高频率上限、充值确认。微信与 WhatsApp 通过同一 Telegram 安排。
- 反馈问题时请附 `request_id`（或 `X-Request-Id`）、任务 id 与时间；Webhook 请附 `X-Puzzle-Event-Id`。均可在控制台（运行历史、Webhook）看到。
- 状态：没有公开状态页。站点的存活 / 就绪探针：`https://puzzle-cloud.com/health/live`、`https://puzzle-cloud.com/health/ready`（`{"status":"ok"}` / `{"status":"ready"}`，未就绪时 503）。合同之外没有可用性保障。
- 机器可读版本：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。
- 充值：在控制台用 USDC 或 USDT（以太坊、波场或 BNB），转账后粘贴哈希，确认到账后计入余额。银行转账或合同加微信或 Telegram——我们开票并入同一余额。没有银行卡结账。

---

## 常见问题

**怎么开始？**  
注册，创建密钥，用 Bearer 调用 /v1。文档里有 curl。提示词可以留，官方 Google SDK 指向我们的域名不会通。

**402 是什么意思？**  
密钥有效，但可用余额低于请求价格。充值、等确认，再重试。402 是正常结果，不是接口挂了。

**提示词会用来训练吗？**  
不会。Puzzle 不用你的提示词或输出训练谷歌或我们自己的模型。

**密钥丢了还能再看吗？**  
不能。Puzzle 只存 SHA-256 哈希。创建时立刻复制。丢了就撤销，再新建一把。

**图片保留多久？**  
完成后 7 天（`result.expires_at`）。请在此之前下载。

**有可用性保障吗？**  
只写在签过的合同里。频率上限按工作区计算（默认每分钟 60 次生成请求），每个生成响应的 `RateLimit-Limit` 头都会返回。需要合同或更高额度——加微信或 Telegram。

**目前可用哪些模型？**  
Nano Banana 2 与 Nano Banana Pro 已上线。Omni、Veo 和 Gemini 在上线前返回 422。

---

SKIPPED WECHAT_ID

*来源：https://puzzle-cloud.com/docs*
