Вебхуки о готовности задач

Как получать уведомление о завершении задачи на свой адрес, проверять подпись X-Epic-Signature и не обработать одно событие дважды.

Обновлено: 4 мин чтения

Укажите адрес в задаче

curl https://epicai.ru/v1/tasks \
  -H "Authorization: Bearer $EPICAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "nano-banana-2",
    "prompt": "Логотип кофейни, минимализм",
    "webhook_url": "https://example.com/hooks/epicai"
  }'

Когда задача завершится, epicai отправит на этот адрес запрос POST. Поле webhook_url принимают также POST /v1/images/generations, POST /v1/audio/speech и POST /v1/audio/transcriptions. Адрес проверяется при создании задачи и должен подходить под условия.

  • Схема https://, порт 443 или без порта, без логина и пароля, не длиннее 500 знаков.
  • Имя узла ведёт на публичный IPv4-адрес. Адреса вроде localhost, домены .local, .internal, .loc и внутренние сети epicai не принимает. Для отладки откройте свой сервер наружу.

Иначе придёт 400 invalid_parameter, где в param указан webhook_url.

Разберите событие

Событий два. task.succeeded приходит, когда задача завершилась успешно, task.failed когда не получилась (в том числе по истечении 100 минут). К этому моменту файлы уже сохранены, а токены рассчитаны, то есть в price.charged стоит итог.

{
  "id": "evt_7fa4be5e14351e3dff994aa3_1",
  "object": "event",
  "type": "task.succeeded",
  "created": 1793114629,
  "data": {
    "id": "task_7fa4be5e14351e3dff994aa3",
    "object": "task",
    "type": "video",
    "model": "kling",
    "status": "succeeded",
    "price": {"tokens": 75000, "charged": 75000},
    "output": [{"type": "video", "url": "https://epicai.ru/storage/api/7fa4be5e14351e3dff994aa3/1.mp4", "mime": "video/mp4", "bytes": 5236392}],
    "expires_at": 1793719429
  }
}

В data лежит та же задача, что отдаёт GET /v1/tasks/{id}, показаны не все её поля. Что значит каждое поле, видно в разделе Задачи в API. Запрос приходит с заголовками:

ЗаголовокЗначение
Content-Typeapplication/json
User-Agentepicai-webhooks/1.0
X-Epic-EventТип события, совпадает с type в теле
X-Epic-SignatureВремя и подпись, вид t=1793114629,v1=…

Проверьте подпись

Без проверки поддельное событие сможет прислать любой, кому известен ваш адрес. Секрет подписи лежит в key.webhook_secret ответа GET /v1/account и выглядит как whsec_…. У каждого ключа он свой, задача подписывается секретом ключа, которым создана. Храните его так же бережно, как сам ключ.

  1. Возьмите из заголовка X-Epic-Signature время t и подпись v1.
  2. Соберите строку из t, точки и тела запроса в тех же байтах, в каких оно пришло, до разбора JSON.
  3. Посчитайте HMAC-SHA256 этой строки. Ключом служит секрет целиком, вместе с whsec_.
  4. Сравните шестнадцатеричную запись с v1 функцией сравнения за постоянное время.
  5. Отбросьте событие, если t расходится с вашим временем больше чем на 5 минут.
import hashlib
import hmac
import time


def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp, signature = parts["t"], parts["v1"]
        age = abs(time.time() - int(timestamp))
    except (KeyError, ValueError):
        return False
    if age > tolerance:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
const crypto = require("crypto");

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

В Python передайте в body сырое тело (во Flask это request.get_data()), в Node.js буфер, который отдаёт express.raw. Тело, уже разобранное в объект и собранное обратно, даст другую подпись.

Отвечайте быстро и не обрабатывайте дважды

Событие считается доставленным, когда ваш сервер ответил кодом 2xx в течение 10 секунд. Другой код, перенаправление, ошибка сертификата и таймаут — это неудача. Ответьте 200 сразу, а файлы скачивайте в фоне: ссылки из data.output открываются без ключа.

После неудачи epicai повторяет доставку. Всего делается 6 попыток, первая в момент завершения, остальные через 1 минуту, 5 минут, 30 минут, 2 часа и 6 часов после предыдущей. Повторы запускает фоновый обход, поэтому пауза бывает чуть длиннее названной. После шестой неудачи доставка прекращается.

Одно событие может прийти дважды, например когда ваш ответ потерялся по дороге. В поле id события для защиты не годится, у каждой попытки оно своё (последняя цифра — номер попытки). Запоминайте data.id, то есть номер задачи, и пропускайте уже обработанные.

Примечание

Вебхук ускоряет получение результата, но не заменяет опрос. Если сервер был недоступен все шесть попыток, задача и файлы остаются доступными по GET /v1/tasks/{id} в течение 7 суток после завершения.

Не нашли ответ?

Посмотрите частые вопросы или напишите в поддержку.

Войти или создать аккаунт

20 000 токенов в подарок новым пользователям. Без карты и без VPN.

или через сервис

Продолжая, вы получаете сервисные письма.

Для защиты от роботов используется Yandex SmartCaptcha. Условия обработки данных.