> ## Documentation Index
> Fetch the complete documentation index at: https://docs.widerouter.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 回撥：讓結果主動推給你

> 給任何任務加一個 callback_url，WideRouter 會在任務結束時把結果 POST 給你。所有模型通用，圖片和影片一樣，不用輪詢迴圈，接收端也不需要 API Key。

非同步 API 上的每個任務都有兩種拿結果的方式。可以一直輪詢
`GET /v1/task/{task_id}` 直到它結束，也可以給 WideRouter 一個 `https` 地址，
讓它主動找你。後者就是回撥，它是任務 API 本身的能力，不屬於某一個模型：
Nano Banana 出圖和 Grok Imagine 出影片，用的是同一個欄位、同一種 payload、同一套規則。

<Info>
  **輪詢每次都要帶你的 API Key，回撥一次都不用。** 接收端是你自己託管的一個 URL，
  由 WideRouter 來呼叫，所以沒有任何憑證需要載入、轉發，也就不會配錯。
  如果你的輪詢程式曾經收到過 `401`，或者同時有幾千個任務在跑，這一頁就是寫給你的。
</Info>

## 提交時多一個欄位

在 `model` 和 `input` 旁邊加上 `callback_url`。請求的其他部分完全不變，
提交響應也還是那個任務 ID。

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.widerouter.com/v1/task/submit \
    -H "Authorization: Bearer $WIDEROUTER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "gemini-3-pro-image",
      "input": { "prompt": "胡桃木桌面上的一隻紙鶴" },
      "callback_url": "https://your-app.example/hooks/widerouter/9f2c1e7b0a4d",
      "callback_secret": "your-callback-secret-at-least-16-bytes"
    }'
  ```

  ```python Python theme={null}
  import os, requests

  resp = requests.post(
      "https://api.widerouter.com/v1/task/submit",
      headers={"Authorization": f"Bearer {os.environ['WIDEROUTER_API_KEY']}"},
      json={
          "model": "gemini-3-pro-image",
          "input": {"prompt": "胡桃木桌面上的一隻紙鶴"},
          "callback_url": "https://your-app.example/hooks/widerouter/9f2c1e7b0a4d",
          "callback_secret": os.environ["WIDEROUTER_CALLBACK_SECRET"],
      },
  )
  task_id = resp.json()["id"]   # 記下來：回撥到達時靠它對號
  ```
</CodeGroup>

兩個欄位都在提交時檢查，寫錯了立刻報錯——URL 錯是 `400 invalid_callback_url`，
金鑰錯是 `400 invalid_params` 且 `param` 指向 `callback_secret`——而不是一個永遠不會回報的任務：

| 規則                                     | 為什麼                                                                            |
| -------------------------------------- | ------------------------------------------------------------------------------ |
| `callback_url` 必須是 `https`             | 任務 payload 裡有你的產物 URL。                                                         |
| 必須是公網可達的主機                             | `localhost`、`*.local`、迴環、內網和鏈路本地地址都會被拒。WideRouter 得能從公網訪問到它。                   |
| 路徑和查詢串隨意                               | 路徑裡還是放一段足夠長的隨機串。不帶 `callback_secret` 時，它是唯一能證明「這個請求來自 WideRouter」的東西。          |
| `callback_secret` 可選：16 到 128 位元組，不含空白 | WideRouter 用它給回撥簽名，見[驗籤](#驗籤)。你自己定，可以一直用同一個，和接收端放在一起。沒有 `callback_url` 時不能單獨傳。 |

## WideRouter 會發給你什麼

每個任務到達終態時 `POST` 一次，`Content-Type: application/json`。
三個頭可以讓你在解析正文之前先分流和驗證來源：

| 頭                  | 值                                                              |
| ------------------ | -------------------------------------------------------------- |
| `X-Wide-Event`     | `task.completed` 或 `task.failed`                               |
| `X-Wide-Task-Id`   | 提交響應裡的任務 ID                                                    |
| `X-Wide-Signature` | `t=<unix 秒>,v1=<hex HMAC-SHA256>`，只在提交時帶了 `callback_secret` 才有 |

正文就是**任務物件，與同一時刻 `GET /v1/task/{task_id}` 的返回逐位元組相同**。
一個解析器同時服務回撥和輪詢兩條路。

<Tabs>
  <Tab title="task.completed">
    ```json theme={null}
    {
      "id": "task_7BeWYLhGoLZYMId5IYuFwc25UQXuFfo6",
      "model": "gemini-3-pro-image",
      "status": "completed",
      "created_at": 1788104905,
      "started_at": 1788104906,
      "completed_at": 1788104925,
      "expires_at": 1788191325,
      "outputs": [
        "https://…/task_7BeWYLhGoLZYMId5IYuFwc25UQXuFfo6_0.jpg"
      ],
      "counts": { "requested": 1, "succeeded": 1, "failed": 0 }
    }
    ```
  </Tab>

  <Tab title="task.failed">
    ```json theme={null}
    {
      "id": "task_jodb26qdNoG5CuwzA23OukOgjeH5ktnh",
      "model": "gemini-3-pro-image",
      "status": "failed",
      "created_at": 1788104830,
      "started_at": 1788104831,
      "completed_at": 1788104831,
      "counts": { "requested": 1, "succeeded": 0, "failed": 1 },
      "error": { "code": "upstream_timeout", "message": "..." }
    }
    ```
  </Tab>
</Tabs>

失敗的任務在傳送回撥之前就已經退款，所以 `task.failed` 同時也是「這筆費用已衝回」的訊號。
產物檔名以任務 ID 開頭，落盤歸檔不需要改名。

## 投遞規則

| 規則     | 值                                                      |
| ------ | ------------------------------------------------------ |
| 算成功    | 任何 `2xx` 響應。其他一切，包括超時，都算一次失敗。                          |
| 單次超時   | 10 秒。先應答，再做慢活。                                         |
| 重試     | 3 次，分別在上一次嘗試之後約 10 秒、1 分鐘、5 分鐘。總共 4 次嘗試，約 6 分鐘，然後放棄。   |
| 順序     | 不保證。按順序提交的兩個任務可能以任意順序回報。                               |
| 對任務的影響 | 沒有。回撥打不通不會改變任務狀態，任務始終可以通過 `GET /v1/task/{task_id}` 讀到。 |

兩個值得據此設計的後果：

* **做到冪等。** 你慢慢地返回了 `200`，我們這邊可能已經超時重試，同一個任務會到兩次。
  handler 按 `id` 去重。
* **給漏網之魚留一條輪詢兜底。** 提交時存下任務 ID。如果超過該模型通常的時延幾分鐘
  還沒收到回撥，就讀一次任務。回撥是省延遲的最佳化，不是投遞保證。

## 驗籤

提交時帶上 `callback_secret`，這個任務的每次回撥都會帶 `X-Wide-Signature`。
它證明兩件事：請求確實來自 WideRouter，正文在路上沒有被改過。它不是加密——
正文裡本來只有公開的 URL。

```
X-Wide-Signature: t=1788884867,v1=7bfbeeeb97ca77af5ee3ab9ba4552a7898390de81478dfa1ef14d8345b5d4874
```

`t` 是傳送回撥時的 Unix 秒。`v1` 是
`hex(HMAC-SHA256(callback_secret, "<t>.<原始正文>"))`：時間戳、一個點、
然後是收到的請求正文原樣。

<Steps>
  <Step title="拿原始正文">
    摘要算的是位元組。把 JSON 解析再序列化一遍位元組就變了，所以要在框架碰它之前把正文讀出來。
  </Step>

  <Step title="重算並恆定時間比較">
    用同一個金鑰重算 HMAC，用恆定時間比較函式比對，不要用 `==`。
  </Step>

  <Step title="拒絕過期的時間戳">
    `now - t` 超過 300 秒的一律丟掉，正負都算。這是防止被截獲的回撥事後重放的手段。
    實測偏差約 3 秒。
  </Step>
</Steps>

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib, time

  def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      t, v1 = int(parts["t"]), parts["v1"]
      if abs(time.time() - t) > tolerance:
          return False
      expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1)
  ```

  ```javascript Node theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verify(secret, header, rawBody, tolerance = 300) {
    const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
    const t = Number(parts.t);
    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
    const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
    const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```java Java theme={null}
  static boolean verify(String secret, String header, byte[] rawBody, long tolerance) throws Exception {
    long t = 0; String v1 = "";
    for (String kv : header.split(",")) {
      String[] p = kv.split("=", 2);
      if (p[0].equals("t")) t = Long.parseLong(p[1]);
      if (p[0].equals("v1")) v1 = p[1];
    }
    if (Math.abs(System.currentTimeMillis() / 1000 - t) > tolerance) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
    return MessageDigest.isEqual(mac.doFinal(rawBody), HexFormat.of().parseHex(v1));
  }
  ```
</CodeGroup>

不帶 `callback_secret` 就沒有簽名頭，`callback_url` 裡那段猜不到的路徑
就是你和一條偽造的「已完成」之間的全部屏障。無論哪種，下載或計費之前都先用你的
API Key 重新讀一次 `GET /v1/task/{task_id}`：簽名證明的是誰發的回撥，
回查證明的是任務現在是什麼狀態。

## 三種語言的接收端

每個 handler 做的是同樣的五件事：對原始正文驗籤，檢查事件頭，立刻用 `200` 應答，
把任務 ID 交給佇列，再由 worker 重新讀任務並下載產物。`verify` 就是上一節那個函式。下載刻意放在請求 handler 之外，
因為產物可能有好幾 MB，而你只有 10 秒的應答時間。

<CodeGroup>
  ```python Python (Flask) theme={null}
  import os, requests
  from flask import Flask, request

  app = Flask(__name__)
  API_KEY = os.environ["WIDEROUTER_API_KEY"]
  CALLBACK_SECRET = os.environ["WIDEROUTER_CALLBACK_SECRET"]
  SEEN = set()   # 生產環境換成你的資料庫

  @app.post("/hooks/widerouter/<secret>")
  def widerouter_hook(secret):
      if secret != os.environ["HOOK_SECRET"]:
          return "", 404
      if not verify(CALLBACK_SECRET, request.headers.get("X-Wide-Signature", ""), request.get_data()):
          return "", 401
      task_id = request.headers.get("X-Wide-Task-Id", "")
      event = request.headers.get("X-Wide-Event", "")
      if task_id in SEEN:
          return "", 200                 # 已處理過的任務被重試投遞
      SEEN.add(task_id)
      enqueue(task_id, event)            # 你的任務佇列，立刻返回
      return "", 200

  def worker(task_id, event):
      task = requests.get(
          f"https://api.widerouter.com/v1/task/{task_id}",
          headers={"Authorization": f"Bearer {API_KEY}"},
      ).json()
      if task["status"] != "completed":
          record_failure(task_id, task.get("error"))
          return
      for i, url in enumerate(task["outputs"]):
          with open(f"{task_id}_{i}.jpg", "wb") as f:
              f.write(requests.get(url, timeout=60).content)
  ```

  ```javascript Node (Express) theme={null}
  import fs from "node:fs";
  import express from "express";

  const app = express();
  const API_KEY = process.env.WIDEROUTER_API_KEY;
  const CALLBACK_SECRET = process.env.WIDEROUTER_CALLBACK_SECRET;
  const seen = new Set(); // 生產環境換成你的資料庫

  // express.raw 保留位元組形式的正文：簽名算的是原始正文
  app.post("/hooks/widerouter/:secret", express.raw({ type: "application/json" }), (req, res) => {
    if (req.params.secret !== process.env.HOOK_SECRET) return res.sendStatus(404);
    if (!verify(CALLBACK_SECRET, req.get("X-Wide-Signature") ?? "", req.body)) return res.sendStatus(401);
    const taskId = req.get("X-Wide-Task-Id");
    const event = req.get("X-Wide-Event");
    if (seen.has(taskId)) return res.sendStatus(200); // 重試投遞，已處理
    seen.add(taskId);
    res.sendStatus(200);                              // 先應答
    setImmediate(() => worker(taskId, event));        // 再做慢活
  });

  async function worker(taskId) {
    const task = await fetch(`https://api.widerouter.com/v1/task/${taskId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    }).then((r) => r.json());
    if (task.status !== "completed") return recordFailure(taskId, task.error);
    for (const [i, url] of task.outputs.entries()) {
      const bytes = Buffer.from(await (await fetch(url)).arrayBuffer());
      await fs.promises.writeFile(`${taskId}_${i}.jpg`, bytes);
    }
  }
  ```

  ```java Java (Spring Boot) theme={null}
  @RestController
  public class WideRouterHook {
    private final String apiKey = System.getenv("WIDEROUTER_API_KEY");
    private final String callbackSecret = System.getenv("WIDEROUTER_CALLBACK_SECRET");
    private final Set<String> seen = ConcurrentHashMap.newKeySet(); // 生產環境換成資料庫

    @PostMapping("/hooks/widerouter/{secret}")
    public ResponseEntity<Void> onTask(@PathVariable String secret,
                                       @RequestHeader("X-Wide-Task-Id") String taskId,
                                       @RequestHeader("X-Wide-Event") String event,
                                       @RequestHeader(value = "X-Wide-Signature", required = false) String signature,
                                       @RequestBody byte[] rawBody) throws Exception {
      if (!secret.equals(System.getenv("HOOK_SECRET"))) return ResponseEntity.notFound().build();
      if (signature == null || !verify(callbackSecret, signature, rawBody, 300)) return ResponseEntity.status(401).build();
      if (!seen.add(taskId)) return ResponseEntity.ok().build();   // 重試投遞，已處理
      executor.submit(() -> download(taskId));                     // 先應答
      return ResponseEntity.ok().build();
    }

    void download(String taskId) throws Exception {
      HttpRequest read = HttpRequest.newBuilder(URI.create("https://api.widerouter.com/v1/task/" + taskId))
          .header("Authorization", "Bearer " + apiKey).GET().build();
      JsonNode task = mapper.readTree(http.send(read, BodyHandlers.ofString()).body());
      if (!"completed".equals(task.get("status").asText())) { recordFailure(taskId, task.get("error")); return; }
      int i = 0;
      for (JsonNode url : task.get("outputs")) {
        HttpRequest get = HttpRequest.newBuilder(URI.create(url.asText())).GET().build();
        Files.write(Path.of(taskId + "_" + (i++) + ".jpg"), http.send(get, BodyHandlers.ofByteArray()).body());
      }
    }
  }
  ```
</CodeGroup>

<Tip>
  worker 讀任務用的是和提交**同一把** API Key。「提交那邊載入了 Key、讀取那邊沒載入」
  是接收端手裡只有任務 ID 卻拿不到圖的頭號原因：每個 `GET` 都是 `401`，
  而每個 `POST` 都成功。兩條路共用一個配置好的客戶端。
</Tip>

## 沒有伺服器也能先試

任何能給你一個公網 `https` 地址的請求檢視服務都可以當一次性接收端：
提交任務時把那個地址填進 `callback_url`，然後看任務物件落進去。
準備在自己機器上接收時，把本地埠打一條 `https` 隧道出去即可。
記住地址必須公網可達，區域網地址在提交時就會被拒。

## 儘快下載

產物 URL 在**任務完成 24 小時後失效**，而回調是你最早知道它們存在的時刻。
到達即下載的接收端永遠不用操心過期；只存 URL、以後再讀的接收端，終有一天會存下一堆指向空的連結。

## 下一步

<CardGroup cols={2}>
  <Card title="非同步任務 API" icon="clock" href="/zh-Hant/api/async-tasks">
    信封、輪詢迴圈、任務狀態與錯誤表。
  </Card>

  <Card title="快速開始" icon="rocket" href="/zh-Hant/quickstart">
    五分鐘內走通提交、輪詢、下載。
  </Card>
</CardGroup>
