> ## 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/api/async-tasks">
    信封、轮询循环、任务状态与错误表。
  </Card>

  <Card title="快速开始" icon="rocket" href="/zh/quickstart">
    五分钟内走通提交、轮询、下载。
  </Card>
</CardGroup>
