spicyapiДокументация
Разработка с API
Основное содержимое

Вебхуки

Различайте тела вебхуков v1 и v2, выбирайте правильный ID задачи и проверяйте подписи с готовыми примерами для Node.js, Python и curl.

Вебхук (или обратный вызов) — это запрос, который мы отправляем на ваш сервер по завершении задачи, чтобы вам не приходилось постоянно запрашивать её состояние. Передайте callBackUrl в createTask: когда задача достигнет конечного состояния, мы отправим POST-запрос. Для успешной задачи мы дожидаемся готовности файлов к скачиванию, поэтому обратный вызов содержит рабочие ссылки. Если доставка не удалась, повторные попытки выполняются по расписанию ниже.

Обратные вызовы отправляются только для конечных состояний. Уведомления о переходе queued → running нет.

Перед началом

  • У вашего сервера должен быть публичный HTTPS-адрес. Наши серверы должны иметь доступ к этому URL через интернет; адрес должен начинаться с https://. http://localhost и адреса внутри офисной или домашней сети не подойдут. При разработке на своём компьютере либо опрашивайте recordInfo, либо используйте туннель, например ngrok или Cloudflare Tunnel, который выдаёт локальному серверу временный публичный HTTPS-адрес. Полные правила приведены в разделе Ограничения URL обратного вызова.
  • Есть два способа получать события, и их можно использовать одновременно. Передать callBackUrl для одной задачи проще всего; зарегистрированный в аккаунте эндпоинт охватывает все задачи без повторной передачи URL. См. Какой способ выбрать.
  • Кнопки отправки тестового события нет. Сначала проверьте свою проверку подписи с помощью скрипта curl ниже, затем создайте одну недорогую реальную задачу с callBackUrl — она оплачивается как любая другая задача. Следите за разделом Console → Webhooks → Webhook deliveries, где каждая доставка отображается со своим статусом.

Какой способ выбрать

callBackUrl для задачиЭндпоинт уровня аккаунта
Где настраиваетсяОтдельное поле в каждом createTaskОдин раз через API; экрана в консоли пока нет
Что доставляетКонечное состояние одной задачиВсе события выбранных типов для всех задач
ЗаголовкиX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
ТелоОбёртка {code, msg, data}, как в recordInfoОбъект события без обёртки, с id, type и data.object
Секрет подписиОдин на аккаунт, в разделе Webhook signing keyОдин на эндпоинт; показывается один раз при создании или ротации
Ротация ключа без перерыва в работеНет: старый секрет сразу перестаёт проходить проверкуДа: в течение переходного периода отправляются обе подписи

Один способ не заменяет другой. Если задача содержит callBackUrl и у аккаунта есть подходящая подписка, будут две доставки в разных форматах. Это нормальная настройка, а не ошибка.

Если вы создаёте интеграцию сейчас и у вас нет веской причины выбрать иной вариант, зарегистрируйте эндпоинт уровня аккаунта: URL задаётся один раз, секрет можно ротировать без одновременного переключения всех получателей, а формат тела знаком большинству библиотек для вебхуков.

Далее описана доставка через callBackUrl, если в разделе не указано иное. Отличия рассмотрены в разделе Эндпоинты уровня аккаунта.

Сначала настройте секрет подписи

Каждая доставка подписывается секретом подписи вашего аккаунта, чтобы сервер мог удостовериться, что запрос пришёл от SpicyAPI. Один секрет используется всеми API-ключами аккаунта.

SpicyAPI показывает секрет только один раз

Обычно у аккаунта уже настроен секрет, но его значение вам никогда не показывалось. Поэтому нужно создать секрет, значение которого вы сможете сохранить:

  1. Откройте Console → Webhooks.
  2. В карточке Webhook signing key нажмите Generate, если указано Not set, или Rotate, если указано Configured.
  3. Сразу скопируйте значение в SPICY_WEBHOOK_SECRET на своём сервере, затем нажмите I’ve saved it. Повторно показать секрет нельзя; если потеряете его, выполните ротацию ещё раз.

Ротация действует немедленно: старый секрет сразу перестаёт проходить проверку, а доставки, для которых продолжаются повторные попытки, подписываются новым. Сразу после ротации обновите всех получателей.

Без секрета подписать доставку невозможно: каждая попытка считается неудачной и повторяется по расписанию. Для задач, чей обратный вызов был пропущен, пока секрет отсутствовал, запрашивайте recordInfo.

Начните с проверки подписи из SDK

Для нового обработчика v2 используйте verifyWebhook из @spicyapi/sdk/webhooks. Функция проверяет подпись исходного тела запроса и временное окно в пять минут, затем возвращает taskId и deliveryId. Она использует криптографический модуль Node.js и должна работать на сервере.

Пример ниже использует Express и PostgreSQL (npm install @spicyapi/sdk express pg). Настройте базу через стандартные переменные окружения PG*, а в SPICY_WEBHOOK_SECRET укажите секрет подписи из консоли. Опубликуйте маршрут за HTTPS. Тело содержит полный input, включая промпт, поэтому пример допускает размер до 10 МБ; настройте свой предел соответственно. Один раз создайте эти две таблицы приложения:

CREATE TABLE spicy_tasks (task_id text PRIMARY KEY);
CREATE TABLE spicy_webhook_inbox (
  delivery_id text PRIMARY KEY,
  task_id text NOT NULL REFERENCES spicy_tasks(task_id),
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

Сохраняйте каждый taskId, полученный при отправке задач вашим приложением, в spicy_tasks. Обработчик принимает события только для этих задач; если необычно быстрый обратный вызов придёт до вставки записи, ответ вне диапазона 2xx позволит повторить доставку. В общем приложении также проверяйте принадлежность задачи соответствующему клиенту.

receiver.mjs
import express from "express";
import pg from "pg";
import { verifyWebhook } from "@spicyapi/sdk/webhooks";

const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET");
const db = new pg.Pool();
const app = express();

app.post("/hooks/spicy", express.raw({ type: "application/json", limit: "10mb" }), async (req, res) => {
  if (req.get("X-Webhook-Payload-Version") !== "2") return res.sendStatus(400);
  let event;
  try {
    event = verifyWebhook({
      rawBody: req.body,
      timestamp: req.get("X-Webhook-Timestamp") ?? "",
      signature: req.get("X-Webhook-Signature") ?? "",
      payloadVersion: 2,
      secret,
      maxBodyBytes: 10 * 1024 * 1024,
    });
    if (!event.deliveryId) return res.sendStatus(400);
  } catch { return res.sendStatus(401); }
  try {
    const owned = await db.query("SELECT 1 FROM spicy_tasks WHERE task_id=$1", [event.taskId]);
    if (!owned.rowCount) return res.sendStatus(404);
    await db.query(
      "INSERT INTO spicy_webhook_inbox(delivery_id,task_id,payload) VALUES($1,$2,$3) ON CONFLICT(delivery_id) DO NOTHING",
      [event.deliveryId, event.taskId, JSON.stringify(event.payload)],
    );
    return res.sendStatus(204);
  } catch { return res.sendStatus(503); }
});
app.listen(8080, "127.0.0.1");

Зарегистрируйте маршрут, читающий исходное тело, до глобального middleware express.json(). Возвращайте 2xx только после фиксации записи во входящей таблице, а обработку сохранённого события поручайте фоновому исполнителю. Первичный ключ делает повторные доставки безопасными: повторная запись ничего не меняет. Проверяйте data.state и data.settled, прежде чем считать результат успешным или стоимость окончательной. Если сохранённый URL медиафайла истёк, снова запросите задачу, пока её результат ещё хранится.

Этот пример новой интеграции принимает v2. Для исторических обработчиков v1 сохраните приведённую ниже проверку с учётом версии; та же функция SDK поддерживает payloadVersion: 1, но в v1 нет deliveryId.

Запрос

POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk…  (base64)
X-Webhook-Payload-Version: 2
ЗаголовокПримечания
X-Webhook-TimestampВремя в секундах Unix для этой доставки. Используется для защиты от повторного воспроизведения запроса
X-Webhook-SignatureHMAC-SHA256 в кодировке base64
X-Webhook-Payload-VersionВерсия формата тела. Для новых интеграций по умолчанию 2; исторические доставки могут использовать 1. Всегда выбирайте обработку по этому заголовку. См. Тело запроса
User-AgentВсегда SpicyAPI-Webhook/1

Мы не следуем перенаправлениям: ответ 3xx от вашего эндпоинта считается неудачной доставкой. Тайм-аут составляет 15 секунд.

Тело запроса

v2 соответствует recordInfo; v1 — старый плоский формат

Обратный вызов версии 2 имеет тот же формат, что и GET /jobs/recordInfo: обёртка {code, msg, data, request_id} и те же поля camelCase внутри data. В версии 1 обёртки нет, а поля задачи в snake_case находятся на верхнем уровне.

Создавайте новые интеграции для v2, но при проверке читайте X-Webhook-Payload-Version: для v1 берите ID задачи из task_id верхнего уровня, для v2 — из data.taskId.

В примерах ниже MODEL_ID_FROM_CATALOG обозначает точный ID модели, изначально выбранный в актуальном каталоге с авторизацией. Это не буквальный ID.

succeeded
{
  "code": 200,
  "msg": "success",
  "request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "succeeded",
    "input": { "prompt": "a folded paper lantern, hard side light" },
    "output": {
      "assets": [
        {
          "key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
          "url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
          "expiresAt": "2026-08-28T09:32:11Z",
          "mime": "image/png",
          "width": 1024,
          "height": 1024,
          "bytes": 1483920
        }
      ]
    },
    "cost": "0.008",
    "settled": true,
    "createdAt": "2026-08-28T09:12:04Z",
    "completedAt": "2026-08-28T09:12:11Z"
  }
}

Пример выше — v2. Справочник полей data находится на странице Асинхронные задачи; он относится и к recordInfo, и к обратным вызовам v2, поэтому здесь не повторяется.

Как и в recordInfo, готовые элементы data.output.assets[] содержат url, expiresAt и метаданные медиафайла. Скачивайте по URL напрямую, без API-ключа; запрос билета на скачивание не нужен. Денежные поля, например cost, представляют собой десятичные строки без $.

Один обратный вызов на задачу, после готовности результата

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

После этого ещё может измениться одно значение, и это не вызывает нового обратного вызова:

  • data.settled равно false → окончательная сумма ещё не определена. Позже запросите recordInfo, чтобы получить итоговый cost.

В редком случае, когда файл результата вообще нельзя сохранить, обратный вызов всё равно придёт: у этого файла будет unavailable: true, но не будет url. Если не удалось сохранить ни один файл задачи, плата не взимается: cost равен "0".

Обратный вызов с state: "succeeded", settled: true и url у каждого файла полон: его можно использовать без дополнительного запроса.

В v2 code всегда равен 200, в том числе для неудачных задач

Не определяйте успех задачи по code

Поле code в обёртке описывает корректность формата доставки, а не успех генерации. Обратный вызов неудачной задачи тоже содержит 200:

failed
{
  "code": 200,
  "msg": "success",
  "request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "failed",
    "errorCode": "upstream_failed",
    "errorMessage": "Generation failed; the charge has been refunded",
    "cost": "0",
    "settled": true,
    "createdAt": "2026-08-28T09:12:04Z",
    "completedAt": "2026-08-28T09:12:39Z"
  }
}

Код if (body.code === 200) markSucceeded() отмечает каждую неудачную задачу как успешную, причём внешне ничто не указывает на ошибку. Успех или неудача указаны в data.state, а причина — в data.errorCode и data.errorMessage. У обратного вызова нет исходного заголовка запроса для выбора языка, поэтому errorMessage следует языковым настройкам аккаунта и по умолчанию выдаётся на английском; см. Язык сообщений об ошибках.

Это соответствует recordInfo, который тоже отвечает 200, когда описываемая задача завершилась неудачей. Оба способа намеренно согласованы: расхождение вернуло бы проблему разных способов читать одни и те же данные, которую этот формат призван устранить.

В v2 request_id — ID доставки; используйте его как ключ идемпотентности

Поле request_id в обёртке имеет вид whd_…. Оно определяет эту запись доставки, а не задачу:

  • При повторных попытках оно не меняется. Автоматические повторы и ручная повторная доставка используют то же бизнес-событие и request_id. Перед каждой отправкой обновляются только временные поля медиафайлов url и expiresAt; не устраняйте дубли v2 по хешу тела.
  • Поэтому проверка «уже обработан ли этот обратный вызов?» сводится к одному действию: создайте уникальный индекс в своей базе и при конфликте возвращайте 2xx.
  • То же поле в ответе recordInfo содержит ID HTTP-запроса (req_…). Смысл в обоих случаях один: идентификатор конкретной доставки.

В v2 задача указана в data.taskId — это ответ на вопрос «какая задача». request_id отвечает на вопрос «какая доставка». В v1 нет request_id; устраняйте дубли ключом, составленным из task_id, состояния и хеша тела.

Заголовок версии соответствует отправляемым байтам

X-Webhook-Payload-Version сообщает, в какой версии сформировано это тело, а не текущую версию в настройках платформы.

Бизнес-содержимое тела и его версия фиксируются при постановке в очередь. При повторной попытке обновляются только временные URL медиафайлов и подписывается ровно то тело, которое отправляется; актуальный снимок задачи не подставляется. Заголовок должен соответствовать этому телу: иначе повторная доставка, простоявшая в очереди несколько часов, придёт с номером версии, противоречащим содержимому, хотя именно по этому заголовку вы выбираете обработку.

v1 — замороженный слой совместимости. Не создавайте новые интеграции для него

Версия тела задаётся общеплатформенным переключателем совместимости, которым управляет SpicyAPI; выбрать её самостоятельно нельзя, а новые доставки используют версию 2. Переключатель может закрепить старый формат 1: snake_case, без обёртки, плоские поля на верхнем уровне, без input, settled и completedAt. Его единственное назначение — не сломать интеграции старого формата в день появления нового.

Новые поля в v1 не добавляются. Все дальнейшие дополнения относятся только к v2. Когда мы убедимся, что v1 больше никто не использует, он будет удалён вместе с настройкой.

Создавайте новые интеграции для v2. Исторические доставки могут всё ещё повторяться в v1, поэтому не определяйте версию по структуре JSON. Читайте X-Webhook-Payload-Version и выбирайте соответствующий путь проверки подписи и разбора, как показано ниже.

Подпись

signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) )

Три части, соединённые .:

  1. ID задачи, выбранный по X-Webhook-Payload-Version: task_id верхнего уровня для v1, data.taskId для v2; никогда не request_id
  2. значение X-Webhook-Timestamp
  3. SHA-256-хеш исходного тела запроса в виде строчных шестнадцатеричных символов

Хеш тела обязателен в подписи, и ваш обработчик должен его вычислять

Если подписывать только ID задачи и время, любой перехвативший одну подлинную доставку сможет объединить эти два значения с собственным телом и повторно отправить запрос на ваш эндпоинт. При проверке по такому опубликованному алгоритму подпись окажется верной.

Это равносильно тому, что мы подтверждаем поддельное сообщение «задача завершена, результат здесь», по которому вы затем списываете деньги с клиента или выдаёте результат. Хеш не является необязательным.

Вычисляйте хеш исходных байтов

Не разбирайте JSON и не сериализуйте его заново перед вычислением хеша. Порядок ключей, пробелы и экранирование Unicode различаются между библиотеками; полученные байты почти наверняка не совпадут с подписанными, и проверка каждый раз будет проваливаться.

Как получить исходное тело: express.raw() в Express, rawBody в Fastify, request.get_data() в Flask, request.body в Django, await request.body() в FastAPI.

Код проверки

Node.js / Express
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.SPICY_WEBHOOK_SECRET;

// The raw bytes are required. express.json() consumes them.
// express.raw() defaults to a 100 KB limit; the body carries your full input.
app.post('/hooks/spicy', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
  if (!verify(req.body, req.headers, SECRET)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const version = req.headers['x-webhook-payload-version'];
  const task = version === '1' ? event : event.data;

  // Acknowledge first, work later. Do not make us wait on your pipeline.
  res.status(200).end();
  enqueue(task);
});

function verify(rawBody, headers, secret) {
  const ts = headers['x-webhook-timestamp'];
  const sig = headers['x-webhook-signature'];
  const version = headers['x-webhook-payload-version'];
  if (!ts || !sig || (version !== '1' && version !== '2')) return false;

  // Replay protection: reject anything outside the tolerance window,
  // however valid its signature
  const timestamp = Number(ts);
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  // v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
  // Nothing parsed out of an unverified body can be trusted yet.
  let event;
  try {
    event = JSON.parse(rawBody.toString('utf8'));
  } catch {
    return false;
  }
  const taskId = version === '1' ? event.task_id : event.data?.taskId;
  if (typeof taskId !== 'string' || !taskId) return false;
  const digest = crypto.createHash('sha256').update(rawBody).digest('hex');
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${taskId}.${ts}.${digest}`)
    .digest('base64');

  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  // timingSafeEqual throws on a length mismatch, so check that first
  if (a.length !== b.length) return false;
  // Constant time — a plain === leaks information through timing
  return crypto.timingSafeEqual(a, b);
}
Python / Flask
import base64, hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["SPICY_WEBHOOK_SECRET"]


@app.post("/hooks/spicy")
def hook():
    # get_data() returns the raw bytes; request.json does not
    raw = request.get_data()
    if not verify(raw, request.headers, SECRET):
        return "", 401

    event = json.loads(raw)
    version = request.headers["X-Webhook-Payload-Version"]
    task = event if version == "1" else event["data"]
    enqueue(task)  # acknowledge first, work later
    return "", 200


def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers.get("X-Webhook-Timestamp")
    sig = headers.get("X-Webhook-Signature")
    version = headers.get("X-Webhook-Payload-Version")
    if not ts or not sig or version not in ("1", "2"):
        return False

    # Replay protection: reject anything outside the tolerance window
    try:
        timestamp = int(ts)
        event = json.loads(raw_body)
    except (ValueError, TypeError, json.JSONDecodeError):
        return False
    if abs(time.time() - timestamp) > 300:
        return False

    # v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
    # Nothing in an unverified body can be trusted yet.
    task_id = event.get("task_id") if version == "1" else event.get("data", {}).get("taskId")
    if not isinstance(task_id, str) or not task_id:
        return False
    digest = hashlib.sha256(raw_body).hexdigest()
    expected = base64.b64encode(
        hmac.new(secret.encode(), f"{task_id}.{ts}.{digest}".encode(), hashlib.sha256).digest()
    ).decode()

    # Constant time
    return hmac.compare_digest(expected, sig)
Go / net/http
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"time"
)

var secret = []byte(os.Getenv("SPICY_WEBHOOK_SECRET"))

// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
type event struct {
	TaskID string `json:"task_id"`
	Data   struct {
		TaskID string `json:"taskId"`
	} `json:"data"`
}

func hook(w http.ResponseWriter, r *http.Request) {
	// The raw bytes are required. Read them once, and never re-encode
	// the parsed JSON: key order and escaping would not survive.
	// The body contains the full input, so allow up to 10 MB.
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if !verify(rawBody, r.Header) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	var parsed event
	if err := json.Unmarshal(rawBody, &parsed); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	taskID := parsed.Data.TaskID
	if r.Header.Get("X-Webhook-Payload-Version") == "1" {
		taskID = parsed.TaskID
	}

	// Acknowledge first, work later. Do not make us wait on your pipeline.
	w.WriteHeader(http.StatusOK)
	enqueue(taskID)
}

func verify(rawBody []byte, headers http.Header) bool {
	ts := headers.Get("X-Webhook-Timestamp")
	signature := headers.Get("X-Webhook-Signature")
	version := headers.Get("X-Webhook-Payload-Version")
	if ts == "" || signature == "" || (version != "1" && version != "2") {
		return false
	}

	// Replay protection: reject anything outside the tolerance window,
	// however valid its signature.
	timestamp, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
		return false
	}

	// Nothing parsed out of an unverified body can be trusted yet.
	var parsed event
	if err := json.Unmarshal(rawBody, &parsed); err != nil {
		return false
	}
	taskID := parsed.Data.TaskID
	if version == "1" {
		taskID = parsed.TaskID
	}
	if taskID == "" {
		return false
	}

	digest := sha256.Sum256(rawBody)
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(taskID + "." + ts + "." + hex.EncodeToString(digest[:])))
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// Constant time — a plain == leaks information through timing.
	return hmac.Equal([]byte(expected), []byte(signature))
}

func enqueue(taskID string) {
	// Your own queue. Return from the handler before doing any real work.
	_ = taskID
}

func main() {
	http.HandleFunc("/hooks/spicy", hook)
	_ = http.ListenAndServe(":8080", nil)
}

Проверьте обработчик с помощью curl

Сохраните пример успешной задачи v2 из раздела Тело запроса в payload.json, задайте в SPICY_WEBHOOK_SECRET тот же секрет, что использует обработчик, и замените URL внизу на свой эндпоинт. Затем выполните:

Test your verifier with curl
# Sign a payload file yourself and post it at your own endpoint. This is the
# only way to confirm your verification code actually passes before go-live.
BODY_FILE=payload.json
VERSION=2
TS=$(date +%s)
TASK_ID=$(jq -r --arg version "$VERSION" \
  'if $version == "1" then .task_id else .data.taskId end' "$BODY_FILE")
DIGEST=$(openssl dgst -sha256 -hex "$BODY_FILE" | awk '{print $NF}')
SIG=$(printf '%s.%s.%s' "$TASK_ID" "$TS" "$DIGEST" \
  | openssl dgst -sha256 -hmac "$SPICY_WEBHOOK_SECRET" -binary \
  | base64)

curl -X POST https://your-app.example.com/hooks/spicy \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $TS" \
  -H "X-Webhook-Signature: $SIG" \
  -H "X-Webhook-Payload-Version: $VERSION" \
  --data-binary "@$BODY_FILE"

Ожидаемый результат: эндпоинт возвращает статус 2xx. Теперь измените один символ SPICY_WEBHOOK_SECRET и повторите запуск — должен вернуться 401. Если по-прежнему приходит 2xx, обработчик не проверяет подписи. Обработчик SDK выше также возвращает 404 для ID задачи, которого нет в spicy_tasks; сначала добавьте туда пример taskId или проверяйте обычный код верификации.

Эндпоинты уровня аккаунта

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

Регистрация

Через API:

curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-app.example.com/hooks/spicy",
    "enabledEvents": ["task.completed"],
    "description": "production"
  }'

Экрана консоли для этого пока нет

Сейчас эндпоинты доступны только через API. Раздел Console → Webhooks управляет общим секретом подписи аккаунта для callBackUrl и показывает доставки; страницы регистрации эндпоинтов в нём нет. Все операции этого раздела выполняются четырьмя приведёнными здесь вызовами; для них нужна сессионная cookie консоли, та же, которой пользуется браузер, а не ключ sk-spicy-.

Ответ содержит secret вида whsec_…. Он показывается ровно один раз. Ни один последующий вызов — ни список, ни подробности — его не возвращает. Если потеряете секрет, остаётся только ротация.

Аккаунт может иметь 16 эндпоинтов. URL должен быть публичным HTTPS-адресом; его проверяют при сохранении и ещё раз при доставке по адресу, в который он фактически разрешается.

События для подписки

СобытиеКогда отправляется
task.completedСокращение для всех четырёх событий ниже. Подписывайтесь на него, если вам не требуется различать их
task.succeededЗадача завершилась
task.failedЗадача завершилась неудачей; резерв обычно освобождается. За отказ по содержимому может взиматься плата, если страница модели прямо указывает это исключение
task.canceledСохранено для исторических записей; задачи, принимаемые сейчас, нельзя отменить
task.expiredЗадача превысила срок выполнения; весь резерв на балансе освобождается

GET /console/v1/webhook-events возвращает этот список при выполнении запроса, поэтому можно создать выбор событий без жёстко заданного перечня.

Подписывайтесь на task.completed вместо перечисления трёх событий из четырёх

Частая ошибка — перечислить succeeded, failed и canceled, забыв expired. В результате код бесконечно ждёт завершения задачи. С task.completed такой пробел невозможен.

Неизвестные названия событий отклоняются при сохранении, а не молча игнорируются: опечатку лучше обнаружить при регистрации, чем через шесть недель заметить, что события одного типа ни разу не пришли. По той же причине подстановки ("*") не принимаются.

Доставка

POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/2
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,9tXk…
{
  "id": "msg_01k3m8x9q2z4v7n5p6r8s0t1w3",
  "type": "task.succeeded",
  "created": 1787045531,
  "api_version": "2026-09-20",
  "data": {
    "object": {
      "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
      "state": "succeeded",
      "settled": true,
      "cost": "0.0300"
    }
  }
}

data.object — та же запись задачи, которую возвращает recordInfo, но это снимок на момент создания события, а не текущее состояние. Даже при обработке доставки спустя три дня вы увидите значения того момента.

settled показывает, окончательна ли стоимость

При отложенном расчёте задача достигает конечного состояния до завершения расчётов, поэтому событие отправляется с settled: false, а cost всё ещё содержит оценку. Уточняющего события не будет. Если вам важна точная сумма, запрашивайте recordInfo, когда settled равно false.

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

signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )

Этот формат соответствует спецификации Standard Webhooks, поэтому библиотека для Stripe или Replicate работает после замены названий заголовков. От подписи callBackUrl выше есть два отличия; ошибка в любом из них приводит к подписи, которая никогда не проходит проверку, без иных признаков ошибки:

  1. Подписывается само тело, а не его хеш. Внутреннего шага sha256 здесь нет.
  2. Ключ HMAC — байты, полученные декодированием base64 после префикса whsec_, а не сама строка. Использование строки — самая частая ошибка: исключений нет, просто подпись всегда неверна.
Node.js
import crypto from 'node:crypto';

// req.body must be the raw bytes, not a parsed object
function verify(req, secret) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const id = req.headers['webhook-id'];
  const ts = req.headers['webhook-timestamp'];

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected =
    'v1,' +
    crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(req.body).digest('base64');

  // The header may hold several space-separated signatures during a rotation
  return String(req.headers['webhook-signature'])
    .split(' ')
    .some((got) => got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)));
}

webhook-id сохраняется при повторных попытках одного сообщения — используйте его как ключ идемпотентности. webhook-timestamp обновляется при каждой попытке: он защищает от повторного воспроизведения запроса, а не от дублирующей обработки.

Ротация секрета без перерыва в работе

curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
  -H 'Content-Type: application/json' \
  -d '{"gracePeriodSeconds": 86400}'

В течение переходного периода каждая доставка содержит две подписи в одном заголовке, разделённые пробелом; достаточно, чтобы прошла проверку любая из них. Для этого предназначен цикл .some(…) выше. Безопасный порядок: развернуть обработчик, принимающий обе подписи → выполнить ротацию → установить новый секрет → дождаться конца переходного периода. Останавливать сервис не требуется.

По умолчанию переходный период составляет 24 часа, максимум — 7 дней. Для значения по умолчанию вообще не передавайте тело запроса.

Если эндпоинт перестал работать

Доставка повторяется по расписанию ниже. Дополнительно действуют следующие правила:

  • Ответ 410 Gone немедленно отключает эндпоинт и удаляет его сообщения из очереди. Повторять запросы на адрес, владелец которого заявил, что его больше нет, бессмысленно. После этого GET /console/v1/webhook-endpoints/{id} возвращает status: "disabled" и disabledReason: "gone_410".
  • Любая другая ошибка увеличивает consecutiveFailures эндпоинта; тот же вызов возвращает этот счётчик. Сейчас автоматического отключения в таких случаях нет.

Неисправный эндпоинт сохраняет подписку

Автоматическое отключение после нескольких дней непрерывных сбоев пока не реализовано. Недоступный эндпоинт, возвращающий ответ, отличный от 410, продолжает получать каждое событие и расходует весь лимит повторов для каждого из них. Самостоятельно проверяйте consecutiveFailures и на время ремонта отправляйте PATCH с {"enabled": false}.

События balance.low и webhook.endpoint.disabled тоже пока недоступны, поэтому их нет в таблице выше.

Повторное включение эндпоинта обнуляет счётчики ошибок, чтобы исправленный эндпоинт не переносил старые сбои в следующий инцидент.

Удаление

Удаление прекращает будущие доставки. Уже поставленные в очередь сообщения сохраняют историю, и вы по-прежнему видите, чем они завершились. Но без эндпоинта их больше нельзя подписать, поэтому они завершаются неудачей, а не исчезают из журнала доставок.

Что отвечать

Любой 2xx означает, что сообщение получено. Любой другой статус, ошибка соединения или ожидание дольше 15 секунд считается неудачей и запускает повторную попытку.

Подтвердите получение до выполнения работы: положите ID задачи в свою очередь и верните ответ. Скачивание, перекодирование и отправка уведомлений внутри запроса обратного вызова приводят к тайм-ауту и повторной доставке, хотя на вашей стороне событие уже частично обработано.

Расписание повторных попыток

ПовторЗадержка после предыдущей попытки
110 секунд
230 секунд
32 минуты
410 минут
530 минут
62 часа
76 часов

Всего предусмотрено восемь автоматических попыток, включая первую доставку. Сумма запланированных задержек — 8 часов 42 минуты 40 секунд; длительность запросов и планирование могут увеличить общее время. После исчерпания попыток автоматические повторы прекращаются, а в разделе Console → Webhooks → Webhook deliveries доставка помечается как failed. Кнопка Retry снова ставит её в очередь; допускается до 6 ручных повторов в минуту. Нельзя вручную повторить доставку, для которой ещё идут автоматические попытки, уже успешную доставку или ту, чьё содержимое удалено по настройкам хранения; для них используйте recordInfo.

Каждая повторная попытка подписывается заново

Время в подписи относится к моменту конкретной доставки, а не к первой попытке. Иначе повтор через шесть часов пришёл бы с временем шестичасовой давности и был отклонён любым корректно реализованным временным окном защиты от воспроизведения. Механизм повторов перестал бы работать именно тогда, когда он нужен.

Поэтому не кешируйте и не сравнивайте временные метки разных доставок. Проверяйте каждую относительно текущего времени.

Идемпотентность

Обратный вызов для одной задачи может прийти несколько раз: из-за повторных попыток, сетевых дублей и ручной повторной доставки. Никогда не предполагайте доставку ровно один раз.

В v2 устраняйте дубли по request_id обёртки (whd_…). Он определяет запись доставки и не меняется при повторах — именно это требуется от ключа идемпотентности. Самая простая реализация — уникальный индекс в вашей базе; при конфликте возвращайте 2xx и завершайте обработку.

В v1 нет request_id. Если вы ещё получаете v1, составляйте ключ идемпотентности из task_id, состояния и хеша исходного тела. Не устраняйте дубли только по ID задачи: это может подавить отдельную будущую запись конечного состояния той же задачи.

Ограничения URL обратного вызова

  • Только https. Обычные URL http:// отклоняются, поскольку тело обратного вызова содержит ваш промпт и подписанные ссылки на результаты.
  • Явно заданный порт может быть только 443 или 80; стандартный порт URL https:// допустим.
  • Не включайте имя пользователя или пароль в URL.
  • Имя хоста должно разрешаться, и каждый полученный адрес должен быть публичным. Отклоняются адреса обратной петли, частных сетей, link-local и метаданных облачной среды.
  • При подключении мы повторно проверяем полученный IP, закрывая возможность DNS-rebinding.

Недопустимые значения callBackUrl при создании задачи, потоковом запросе или расчёте цены возвращают HTTP 400 с бизнес-полями code: 400, msg: "Invalid callback URL" и request_id. Ответ не повторяет переданный URL, имя хоста, IP-адрес или диагностику DNS.

Обе проверки защищают от использования наших серверов для проникновения в частную сеть. Поэтому http://localhost:3000 не подойдёт для локальной разработки: используйте туннель, например ngrok или Cloudflare Tunnel, для публичного HTTPS-адреса либо опрашивайте состояние во время локальной разработки.

На этой странице