> ## 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-уведомлений с callback

> Укажите callback_url для любой задачи, и WideRouter отправит вам готовый результат. Работает с любой моделью, изображениями и видео — без цикла опроса и без ключа API на стороне получателя.

Каждая задача в асинхронном API может быть доставлена двумя способами. Вы можете отправлять запросы к
`GET /v1/task/{task_id}` до её завершения или передать WideRouter URL
`https` и позволить результату прийти к вам. Второй способ — это callback, и он
является свойством самого API задач, а не какой-либо отдельной модели: одно и то же поле, одинаковые
данные и одни и те же правила применяются как к изображению Nano Banana, так и к видео
Grok Imagine.

<Info>
  **Для опроса требуется ваш API-ключ в каждом запросе. Для callback он не нужен.** Получателем
  является URL, размещённый вами; WideRouter вызывает его, поэтому нет учётных данных, которые нужно
  загружать, передавать или указывать с ошибкой. Если ваш обработчик опроса когда-либо отвечает `401` или у вас
  выполняются тысячи задач, эта страница для вас.
</Info>

## Одно поле при отправке

Добавьте `callback_url` рядом с `model` и `input`. Ничего другого в запросе
не изменится, а в ответе на отправку будет тот же идентификатор задачи, который
вы получили бы в обычном случае.

<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`; некорректный секрет — это `400 invalid_params`, если `param`
имеет значение `callback_secret`:

| Правило                                                                    | Причина                                                                                                                                                                                                                            |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callback_url` должен быть `https`                                         | Полезные нагрузки задач содержат URL ваших результатов.                                                                                                                                                                            |
| Должен быть общедоступный хост                                             | Адреса `localhost`, `*.local`, loopback, частные и link-local-адреса отклоняются. WideRouter должен иметь возможность обратиться к нему из интернета.                                                                              |
| Любые путь и параметры запроса на ваш выбор                                | В любом случае добавьте в путь длинный случайный сегмент. Без `callback_secret` это единственное, что подтверждает: запрос поступил от WideRouter.                                                                                 |
| `callback_secret` необязателен: от 16 до 128 байт, без пробельных символов | WideRouter подписывает им callback — см. [Проверка подписи](#verifying-the-signature). Выберите его самостоятельно, используйте повторно для разных задач и храните рядом с обработчиком. Его нельзя отправить без `callback_url`. |

## Что WideRouter отправляет вам

Один `POST` на каждую задачу, `Content-Type: application/json`, когда задача достигает
конечного состояния. Три заголовка позволяют выполнить маршрутизацию и аутентификацию до разбора
тела:

| Заголовок          | Значение                                                                                                |
| ------------------ | ------------------------------------------------------------------------------------------------------- |
| `X-Wide-Event`     | `task.completed` или `task.failed`                                                                      |
| `X-Wide-Task-Id`   | идентификатор задачи из ответа на отправку                                                              |
| `X-Wide-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`, только если задача была отправлена с помощью `callback_secret` |

Тело — это **объект задачи, байт в байт совпадающий с тем, что `GET /v1/task/{task_id}`
возвращает в данный момент**. Один парсер обслуживает и callback, и путь
опроса.

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

Для неуспешной задачи выполняется возврат средств до отправки callback, поэтому `task.failed` также
служит для вас сигналом о том, что списание было отменено. Выходные файлы называются по
идентификатору задачи, поэтому их легко сохранять без переименования.

## Правила доставки

| Правило             | Значение                                                                                                                                                                                      |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Успех               | Любой ответ `2xx`. Всё остальное, включая тайм-аут, считается неудачной попыткой.                                                                                                             |
| Тайм-аут на попытку | 10 секунд. Сначала ответьте, а длительную обработку выполните после этого.                                                                                                                    |
| Повторные попытки   | 3 попытки — примерно через 10 секунд, 1 минуту и 5 минут после предыдущей попытки. Всего выполняется четыре попытки в течение примерно шести минут, после чего WideRouter прекращает попытки. |
| Порядок             | Не гарантируется. Две задачи, отправленные по порядку, могут вернуть результаты в любом порядке.                                                                                              |
| Влияние на задачу   | Отсутствует. Неработающий callback никогда не изменяет состояние задачи; задача остаётся доступной через `GET /v1/task/{task_id}`.                                                            |

При проектировании следует учитывать два важных последствия:

* **Обеспечьте идемпотентность.** Повторная попытка после медленного ответа `200` означает, что одна и та же задача может поступить дважды. Используйте `id` в качестве ключа обработчика.
* **Предусмотрите резервный вариант с опросом для задержавшихся задач.** Сохраняйте идентификатор задачи во время отправки. Если callback не поступил через несколько минут после обычной задержки модели, один раз запросите состояние задачи. Callback оптимизирует задержку, но не гарантирует доставку.

## Проверка подписи

При отправке передайте `callback_secret`, и каждый callback для этой задачи будет содержать
`X-Wide-Signature`. Это доказывает два факта: запрос поступил от WideRouter, а
тело не было изменено при передаче. Это не шифрование — тело содержит только
общедоступные URL.

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

`t` — это Unix-время отправки callback. `v1` — это
`hex(HMAC-SHA256(callback_secret, "<t>.<raw body>"))`: метка времени, точка,
затем тело запроса в точности в том виде, в котором оно было получено.

<Steps>
  <Step title="Получите необработанное тело">
    Дайджест вычисляется по байтам. Разбор JSON и его повторная сериализация изменяют
    байты, поэтому прочитайте тело до того, как его обработает какой-либо фреймворк.
  </Step>

  <Step title="Повторно вычислите значение и сравните за постоянное время">
    Повторно создайте HMAC с тем же секретом и сравните его с помощью функции,
    выполняющей сравнение за постоянное время, а не с помощью `==`.
  </Step>

  <Step title="Отклоняйте устаревшие метки времени">
    Отбрасывайте всё, для чего `t` отличается от показаний ваших часов
    более чем на 300 секунд в любом направлении. Именно это не позволяет
    повторно воспроизвести перехваченный callback позднее. Измеренное расхождение
    при тестировании составило около 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` — единственное, что защищает вас от поддельного статуса
«завершено». В любом случае повторно прочитайте `GET /v1/task/{task_id}` с помощью вашего
API-ключа, прежде чем скачивать результаты задачи или выполнять тарификацию:
подпись подтверждает, кто отправил callback, а повторное чтение показывает,
каков текущий статус задачи.

## Обработчик-получатель на трёх языках

Каждый обработчик выполняет одни и те же пять действий: проверяет подпись для
исходного тела запроса, проверяет заголовок события, немедленно подтверждает
получение с помощью `200`, передаёт идентификатор задачи в очередь и позволяет
воркеру повторно прочитать задачу и скачать выходные данные.
Функция `verify` — это функция из предыдущего раздела. Загрузка намеренно выполняется
за пределами обработчика запроса, поскольку выходные данные могут занимать несколько
мегабайт, а на ответ у вас есть десять секунд.

<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, который используется для отправки. Ключ,
  загруженный для отправки, но не настроенный для пути чтения, — наиболее частая
  причина того, что обработчик получает идентификаторы задач, но не изображения:
  каждый ответ `GET` возвращает `401`, тогда как каждый `POST` завершается успешно.
  Используйте один настроенный клиент для обоих действий.
</Tip>

## Тестирование без сервера

Любой сервис инспекции запросов, который предоставляет общедоступный `https` URL, подходит в качестве временного приемника: отправьте задачу, указав этот URL в качестве `callback_url`, и наблюдайте, как объект задачи появляется в инспекторе. Когда вы будете готовы принимать запросы на собственной машине, туннель `https` к локальному порту выполнит ту же функцию. Помните, что URL должен быть доступен из интернета; адрес в локальной сети отклоняется при отправке
задачи.

## Скачивайте своевременно

URL вывода перестают работать **через 24 часа после завершения задачи**, а callback —
самый ранний момент, когда вы узнаёте об их существовании. Получатель, который скачивает
данные при поступлении, никогда не должен думать об истечении срока; тот, который лишь сохраняет URL и обращается
к нему позже, в конечном итоге будет хранить ссылки, ведущие в никуда.

## Следующие шаги

<CardGroup cols={2}>
  <Card title="API асинхронных задач" icon="clock" href="/ru/api/async-tasks">
    Конверт, цикл опроса, состояния задач и таблица ошибок.
  </Card>

  <Card title="Быстрый старт" icon="rocket" href="/ru/quickstart">
    Отправьте, опросите и скачайте примерно за пять минут.
  </Card>
</CardGroup>
