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

# Push delivery with callbacks

> Give any task a callback_url and WideRouter posts the finished result to you. Works for every model, image or video, with no polling loop and no API key on the receiving side.

Every task on the async API can be delivered two ways. You can poll
`GET /v1/task/{task_id}` until it finishes, or you can hand WideRouter an
`https` URL and let it come to you. The second path is the callback, and it is
a property of the task API itself, not of any one model: the same field, the
same payload and the same rules apply to a Nano Banana image and a Grok Imagine
video.

<Info>
  **Polling needs your API key on every request. A callback needs none.** The
  receiver is a URL you host; WideRouter calls it, so there is no credential to
  load, forward or get wrong. If your poller ever answers `401`, or you have
  thousands of tasks in flight, this is the page for you.
</Info>

## One field on submit

Add `callback_url` next to `model` and `input`. Nothing else about the request
changes, and the submit response is the same task id you would get otherwise.

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

Both fields are checked at submit time, so a mistake fails immediately instead
of producing a task that silently never reports back. A bad URL is
`400 invalid_callback_url`; a bad secret is `400 invalid_params` with `param`
set to `callback_secret`:

| Rule                                                          | Why                                                                                                                                                                                                                 |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callback_url` must be `https`                                | Task payloads contain your output URLs.                                                                                                                                                                             |
| Must be a public host                                         | `localhost`, `*.local`, loopback, private and link-local addresses are rejected. WideRouter has to be able to reach it from the internet.                                                                           |
| Any path and query you like                                   | Put a long random segment in the path anyway. Without a `callback_secret` it is the only thing that proves a request came from WideRouter.                                                                          |
| `callback_secret` is optional: 16 to 128 bytes, no whitespace | WideRouter signs the callback with it, see [Verifying the signature](#verifying-the-signature). Pick it yourself, reuse it across tasks, keep it next to your receiver. It cannot be sent without a `callback_url`. |

## What WideRouter sends you

One `POST` per task, `Content-Type: application/json`, when the task reaches a
terminal state. Three headers let you route and authenticate before you parse
the body:

| Header             | Value                                                                                              |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| `X-Wide-Event`     | `task.completed` or `task.failed`                                                                  |
| `X-Wide-Task-Id`   | the task id from the submit response                                                               |
| `X-Wide-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`, only when the task was submitted with a `callback_secret` |

The body is **the task object, byte for byte what `GET /v1/task/{task_id}`
returns at that moment**. One parser serves both the callback and the polling
path.

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

A failed task is refunded before the callback is sent, so `task.failed` is also
your signal that the charge has been reversed. Output files are named after the
task id, which makes them easy to file without renaming.

## Delivery rules

| Rule                | Value                                                                                                                                                 |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Success             | Any `2xx` response. Everything else, including a timeout, counts as a failed attempt.                                                                 |
| Timeout per attempt | 10 seconds. Respond first, do the slow work after.                                                                                                    |
| Retries             | 3, at roughly 10 seconds, 1 minute and 5 minutes after the previous attempt. Four attempts in total over about six minutes, then WideRouter gives up. |
| Ordering            | Not guaranteed. Two tasks submitted in order can report back in either order.                                                                         |
| Effect on the task  | None. A dead callback never changes task state; the task stays readable through `GET /v1/task/{task_id}`.                                             |

Two consequences worth designing around:

* **Be idempotent.** A retry after a slow `200` means the same task can arrive
  twice. Key your handler on `id`.
* **Keep a polling fallback for stragglers.** Store the task id at submit time.
  If a callback has not arrived a few minutes after the model's usual latency,
  read the task once. A callback is a latency optimization, not a delivery
  guarantee.

## Verifying the signature

Send a `callback_secret` on submit and every callback for that task carries
`X-Wide-Signature`. It proves two things: the request came from WideRouter, and
the body was not altered on the way. It is not encryption — the body only ever
holds public URLs.

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

`t` is the Unix time the callback was sent. `v1` is
`hex(HMAC-SHA256(callback_secret, "<t>.<raw body>"))`: the timestamp, a dot,
then the request body exactly as received.

<Steps>
  <Step title="Take the raw body">
    The digest is over bytes. Parsing the JSON and serializing it again changes
    the bytes, so read the body before any framework touches it.
  </Step>

  <Step title="Recompute and compare in constant time">
    Rebuild the HMAC with the same secret and compare with a constant-time
    function, never with `==`.
  </Step>

  <Step title="Reject stale timestamps">
    Drop anything whose `t` is more than 300 seconds away from your clock in
    either direction. That is what stops a captured callback from being replayed
    later. Measured skew in testing was about 3 seconds.
  </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>

Without a `callback_secret` there is no signature header, and the unguessable
path in `callback_url` is the only thing between you and a forged "completed".
Either way, re-read `GET /v1/task/{task_id}` with your API key before you
download or bill on a task: the signature proves who sent the callback, the
read proves what the task is now.

## A receiver in three languages

Each handler does the same five things: verify the signature over the raw
body, check the event header, acknowledge with `200` immediately, hand the task
id to a queue, and let a worker re-read the task and download the outputs.
The `verify` function is the one from the previous section. The download is deliberately outside the
request handler because outputs can be several megabytes and you have ten
seconds to answer.

<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>
  The worker reads the task with the **same** API key you submit with. A key
  that is loaded for submit but not for the read path is the single most common
  way a receiver ends up with task ids and no images: every `GET` answers `401`
  while every `POST` succeeds. Keep one configured client for both.
</Tip>

## Trying it without a server

Any request-inspection service that gives you a public `https` URL works as a
throwaway receiver: submit a task with that URL as `callback_url` and watch the
task object land in the inspector. When you are ready to receive on your own
machine, an `https` tunnel to your local port does the same job. Remember that
the URL must be reachable from the internet; a LAN address is rejected at
submit time.

## Download promptly

Output URLs stop working **24 hours after the task completes**, and the callback
is the earliest moment you know they exist. A receiver that downloads on
arrival never has to think about expiry; one that only stores the URL and reads
it later will eventually store links to nothing.

## Next steps

<CardGroup cols={2}>
  <Card title="Async task API" icon="clock" href="/api/async-tasks">
    The envelope, the polling loop, task states and the error table.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Submit, poll and download in about five minutes.
  </Card>
</CardGroup>
