> ## 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 キーも必要ありません。

非同期 API の各タスクは、2 つの方法で処理できます。完了するまで
`GET /v1/task/{task_id}` をポーリングするか、`https` URL を WideRouter に渡して、
結果を受け取ることができます。後者がコールバックであり、特定のモデルに
固有のものではなく、タスク API 自体の機能です。同じフィールド、同じ
ペイロード、同じルールが、Nano Banana の画像にも Grok Imagine の
動画にも適用されます。

<Info>
  **ポーリングではリクエストごとに API キーが必要です。コールバックでは不要です。** 受信先はお客様がホストする URL です。WideRouter がそこへ呼び出すため、認証情報を読み込んだり、転送したり、誤って設定したりする必要はありません。ポーラーが `401` を返すことがある場合や、数千件のタスクを同時に実行している場合は、このページをご覧ください。
</Info>

## 送信時の 1 つのフィールド

`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": "a paper crane on a walnut desk" },
      "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": "a paper crane on a walnut desk"},
          "callback_url": "https://your-app.example/hooks/widerouter/9f2c1e7b0a4d",
          "callback_secret": os.environ["WIDEROUTER_CALLBACK_SECRET"],
      },
  )
  task_id = resp.json()["id"]   # remember it: it is how you match the callback
  ```
</CodeGroup>

どちらのフィールドも送信時にチェックされるため、間違いがあるとすぐに失敗し、
タスクが作成されたまま応答を返さない状態になることはありません。不正な URL は
`400 invalid_callback_url`となり、不正なシークレットは、`param`を
`callback_secret`に設定すると`400 invalid_params`となります。

| ルール                                    | 理由                                                                                                                                     |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `callback_url`は`https`である必要があります       | タスクペイロードには出力 URL が含まれます。                                                                                                               |
| 公開ホストである必要があります                        | `localhost`、`*.local`、ループバック、プライベートおよびリンクローカルアドレスは拒否されます。WideRouter がインターネットからアクセスできる必要があります。                                          |
| 任意のパスとクエリを指定できます                       | それでも、パスには長いランダムなセグメントを含めてください。`callback_secret`がなければ、それだけがリクエストが WideRouter から送信されたことを証明する手段になります。                                     |
| `callback_secret`は任意です：16～128 バイト、空白なし | WideRouter はこれを使用してコールバックに署名します。[署名の検証](#verifying-the-signature)を参照してください。値は自分で決め、タスク間で再利用し、受信側の隣に保管してください。`callback_url`なしでは送信できません。 |

## WideRouter から送信される内容

タスクごとに `POST` が 1 つ送信され、タスクが
終端状態に達した時点で `Content-Type: application/json` されます。本文を解析する前にルーティングと
認証を行うための 3 つのヘッダーがあります。

| ヘッダー               | 値                                                                             |
| ------------------ | ----------------------------------------------------------------------------- |
| `X-Wide-Event`     | `task.completed` または `task.failed`                                            |
| `X-Wide-Task-Id`   | 送信レスポンスに含まれるタスク ID                                                            |
| `X-Wide-Signature` | `t=<unix seconds>,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 秒です。先に応答し、時間のかかる処理は後で実行してください。                                                        |
| リトライ        | 前回の試行からおよそ 10 秒後、1 分後、5 分後に 3 回実行されます。合計 4 回の試行が約 6 分間にわたって行われ、その後 WideRouter は処理を停止します。 |
| 順序          | 保証されません。順番に送信された 2 つのタスクでも、どちらが先に返されるかは異なる場合があります。                                       |
| タスクへの影響     | ありません。無効なコールバックによってタスクの状態が変わることはなく、タスクは `GET /v1/task/{task_id}` を通じて引き続き読み取り可能です。       |

設計時に考慮すべき重要な点は 2 つあります。

* **冪等性を確保してください。** 処理に時間のかかる `200` の後にリトライが行われると、同じタスクが 2 回到着する可能性があります。`id` をキーとしてハンドラーを識別してください。
* **応答が遅れているタスク用のポーリングフォールバックを用意してください。** 送信時にタスク ID を保存します。
  モデルの通常のレイテンシから数分経過してもコールバックが到着しない場合は、タスクを 1 回読み取ってください。コールバックはレイテンシを最適化するためのものであり、配信を保証するものではありません。

## 署名の検証

送信時に `callback_secret` を送り、そのタスクに対するすべてのコールバックには
`X-Wide-Signature` が付与されます。これにより、リクエストが WideRouter から送信されたことと、
途中で本文が変更されていないことの2点を証明できます。これは暗号化ではありません。本文に含まれるのは
公開 URL のみです。

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

`t` はコールバックが送信された Unix 時刻です。`v1` は
`hex(HMAC-SHA256(callback_secret, "<t>.<raw body>"))` です。つまり、タイムスタンプ、ドット、
その後にリクエスト本文を受信したままの形式で連結したものです。

<Steps>
  <Step title="未加工の本文を取得する">
    ダイジェストはバイト列に対して計算されます。JSON を解析して再度シリアライズするとバイト列が変わるため、
    フレームワークが処理する前に本文を読み取ってください。
  </Step>

  <Step title="再計算し、定時間で比較する">
    同じシークレットで HMAC を再構築し、必ず定時間比較関数を使って比較してください。`==`
    は使用しないでください。
  </Step>

  <Step title="古いタイムスタンプを拒否する">
    `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` にある推測困難な
パスだけが、偽造された「completed」からあなたを守るものになります。
いずれの場合も、タスクをダウンロードまたは課金する前に、API キーを使って `GET /v1/task/{task_id}` を
再度読み取ってください。署名はコールバックの送信者を証明し、読み取り結果はタスクの現在の状態を証明します。

## 3つの言語でのレシーバー

各ハンドラーは同じ5つの処理を行います。生の
body に対する署名を検証し、イベントヘッダーを確認し、直ちに `200` で応答し、タスク
id をキューに渡し、ワーカーにタスクを再読み込みさせて出力をダウンロードさせます。
`verify` 関数は前のセクションのものです。出力は数メガバイトになる可能性があり、応答まで
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()   # use your database in production

  @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                 # retry of a task we already handled
      SEEN.add(task_id)
      enqueue(task_id, event)            # your job queue; returns instantly
      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(); // use your database in production

  // express.raw keeps the body as bytes: the signature is over the raw body
  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); // retry, already handled
    seen.add(taskId);
    res.sendStatus(200);                              // acknowledge first
    setImmediate(() => worker(taskId, event));        // then do the slow part
  });

  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(); // use a DB in production

    @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();   // retry, already handled
      executor.submit(() -> download(taskId));                     // acknowledge first
      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>
  ワーカーは、送信時に使用するものと**同じ** API キーでタスクを読み取ります。送信時には読み込まれるものの、読み取りパスには読み込まれないキーは、レシーバーがタスク
  id を取得しても画像を取得できない最も一般的な原因です。すべての `GET` が `401` を返す一方で、すべての `POST` は成功します。両方に対して設定済みのクライアントを1つ使用してください。
</Tip>

## サーバーなしで試す

公開 `https` URL を提供するリクエスト検査サービスであれば、使い捨ての受信先として利用できます。その URL を `callback_url` としてタスクを送信し、検査サービスにタスクオブジェクトが届く様子を確認します。自分のマシンで受信する準備ができたら、ローカルポートへの `https` トンネルでも同じことができます。URL はインターネットから到達可能である必要があり、LAN アドレスは送信時に拒否される点に注意してください。

## 速やかにダウンロードする

出力 URL は、**タスクの完了から 24 時間後**に機能しなくなります。コールバックは、それらの URL の存在を知ることができる最も早い時点です。受信時にダウンロードする受信側であれば、有効期限を気にする必要はありません。一方、URL だけを保存して後から読み取る受信側では、最終的に何も指さないリンクを保存することになります。

## 次のステップ

<CardGroup cols={2}>
  <Card title="非同期タスク API" icon="clock" href="/ja/api/async-tasks">
    エンベロープ、ポーリングループ、タスクの状態、エラーテーブルについて説明します。
  </Card>

  <Card title="クイックスタート" icon="rocket" href="/ja/quickstart">
    約5分で送信、ポーリング、ダウンロードを行えます。
  </Card>
</CardGroup>
