spicyapiDocumentação
Desenvolver com a API
Conteúdo principal

Webhooks

Diferencie os payloads v1 e v2, selecione o ID correto da tarefa e verifique assinaturas com exemplos funcionais em Node.js, Python e curl.

Um webhook, ou callback, é uma solicitação que nós enviamos ao seu servidor quando uma tarefa termina, para que você não precise consultar repetidamente. Envie callBackUrl em createTask e faremos uma entrega POST quando a tarefa chegar a um estado terminal. Para uma tarefa bem-sucedida, aguardamos até que seus arquivos estejam prontos para download, de modo que o callback contenha links funcionais. Entregas que falham seguem o cronograma de novas tentativas abaixo.

Callbacks são enviados apenas em estados terminais. Não há notificação de queued → running.

Antes de começar

  • Seu servidor precisa de um endereço HTTPS público. Nossos servidores devem conseguir acessar a URL pela internet, e ela deve começar com https://. http://localhost e endereços dentro da rede da sua casa ou escritório não funcionam. Ao desenvolver no seu computador, consulte por recordInfo ou use uma ferramenta de túnel — como ngrok ou Cloudflare Tunnel — que forneça ao servidor local um endereço HTTPS público temporário. As regras completas estão em Restrições da URL de callback.
  • Há duas formas de receber eventos, e elas coexistem. Enviar callBackUrl em uma tarefa individual é a maneira mais rápida; registrar um endpoint na conta cobre todas as tarefas sem repetir a URL. Veja Qual opção usar.
  • Não há um botão de “enviar evento de teste”. Teste primeiro a verificação da assinatura com o script curl abaixo, depois crie uma tarefa real de baixo custo com callBackUrl — ela será cobrada como qualquer outra — e acompanhe Console → Webhooks → Webhook deliveries, onde cada entrega aparece com seu status.

Qual opção usar

callBackUrl em uma tarefaEndpoint no nível da conta
Onde configurarUm campo em cada createTaskUma vez, pela API — ainda não há uma tela no console
O que entregaO estado terminal daquela tarefaTodos os tipos de evento assinados, para todas as tarefas
CabeçalhosX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
CorpoO envelope {code, msg, data}, igual ao de recordInfoUm objeto de evento sem envelope, com id, type e data.object
Segredo de assinaturaUm por conta, em Webhook signing keyUm por endpoint, exibido uma vez na criação ou rotação
Rotação da chave sem interrupçãoNão — o segredo antigo deixa de verificar imediatamenteSim — ambas as assinaturas são enviadas durante o período de transição

Uma opção não substitui a outra. Uma tarefa com callBackUrl e pertencente a uma conta com uma assinatura de eventos correspondente gera duas entregas, uma em cada formato; essa é uma configuração normal, não um erro.

Se você está integrando agora e não tem um motivo forte para escolher uma delas, registre um endpoint no nível da conta: a URL é definida uma vez, o segredo pode ser rotacionado sem uma troca simultânea obrigatória e o payload usa um formato que a maioria das bibliotecas de webhook já entende.

O restante desta página descreve a entrega de callBackUrl, salvo indicação contrária. Endpoints no nível da conta explica as diferenças.

Configure primeiro um segredo de assinatura

Cada entrega é assinada com o segredo de assinatura da sua conta, permitindo que seu servidor confirme que a solicitação veio da SpicyAPI. Um único segredo é compartilhado por todas as chaves de API da conta.

A SpicyAPI exibe o segredo apenas uma vez

As contas normalmente começam com um segredo já configurado, mas seu valor nunca foi exibido para você; por isso, é necessário criar um que você possa ver:

  1. Abra Console → Webhooks.
  2. No cartão Webhook signing key, clique em Generate, se aparecer Not set, ou em Rotate, se aparecer Configured.
  3. Copie o valor diretamente para SPICY_WEBHOOK_SECRET no seu servidor e clique em I’ve saved it. Ele não poderá ser exibido novamente; se você o perder, faça outra rotação.

A rotação tem efeito imediato: o segredo antigo deixa de verificar na hora, e as entregas ainda em novas tentativas passam a ser assinadas com o novo. Atualize todos os receptores logo após a rotação.

Sem um segredo, a entrega não pode ser assinada: cada tentativa conta como falha e segue o cronograma de novas tentativas. Consulte recordInfo para qualquer tarefa cujo callback não tenha chegado enquanto o segredo estava indisponível.

Comece pelo verificador do SDK

Para um novo receptor v2, use verifyWebhook de @spicyapi/sdk/webhooks. Ele verifica a assinatura do corpo bruto e a janela de cinco minutos do timestamp, e retorna taskId e deliveryId. Esse helper usa o módulo crypto do Node.js; mantenha-o no servidor.

O exemplo abaixo usa Express e PostgreSQL (npm install @spicyapi/sdk express pg). Configure o banco com as variáveis de ambiente habituais PG* e defina SPICY_WEBHOOK_SECRET com o segredo de assinatura do console. Disponibilize a rota por HTTPS. O corpo contém seu input completo, incluindo o prompt, portanto o exemplo aceita até 10 MB; ajuste seu limite de acordo com a necessidade. Crie estas duas tabelas da aplicação uma vez:

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()
);

Salve cada taskId retornado pelos seus próprios envios de tarefas em spicy_tasks. O receptor aceita eventos apenas dessas tarefas; se um callback excepcionalmente rápido chegar antes da inserção, a resposta fora de 2xx permite uma nova tentativa de entrega. Em uma aplicação compartilhada, aplique também a regra de propriedade de tarefas do seu tenant.

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");

Registre essa rota de corpo bruto antes de um middleware global express.json(). Retorne 2xx apenas depois de confirmar a gravação na inbox e deixe um worker em segundo plano processar o evento armazenado. A chave primária faz com que entregas duplicadas não tenham efeito. Verifique data.state e data.settled antes de considerar um resultado bem-sucedido ou seu custo definitivo. Se uma URL de mídia armazenada tiver expirado, consulte novamente a tarefa enquanto o resultado ainda estiver retido.

Este exemplo para novas integrações aceita v2. Para receptores históricos v1, mantenha a verificação sensível à versão apresentada abaixo; o mesmo helper do SDK aceita payloadVersion: 1, mas v1 não tem deliveryId.

A solicitação

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
CabeçalhoObservações
X-Webhook-TimestampSegundos Unix desta entrega. Usado para proteção contra replay
X-Webhook-SignatureHMAC-SHA256 codificado em base64
X-Webhook-Payload-VersionVersão do formato do corpo. Novas integrações usam 2 por padrão; entregas históricas ainda podem usar 1. Sempre escolha o fluxo por esse cabeçalho. Veja O payload
User-AgentSempre SpicyAPI-Webhook/1

Não seguimos redirecionamentos — uma resposta 3xx do seu endpoint conta como entrega malsucedida. O timeout é de 15 segundos.

O payload

v2 corresponde a recordInfo; v1 é o formato plano legado

Um callback de versão 2 tem o mesmo formato de GET /jobs/recordInfo: o envelope {code, msg, data, request_id} e os mesmos campos camelCase dentro de data. A versão 1 não tem envelope e coloca os campos da tarefa em snake_case no nível superior.

Desenvolva novas integrações para v2, mas faça o ponto de entrada da verificação ler X-Webhook-Payload-Version: obtenha o ID da tarefa em task_id, no nível superior, para v1, e em data.taskId para v2.

Os exemplos abaixo usam MODEL_ID_FROM_CATALOG para representar o ID exato do modelo originalmente selecionado no catálogo autenticado ao vivo. Não se trata de um ID literal.

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

O exemplo acima é v2. A referência dos campos de data está em Tarefas assíncronas; ela se aplica tanto a recordInfo quanto aos callbacks v2, por isso não é repetida aqui.

Assim como em recordInfo, os arquivos prontos de data.output.assets[] incluem url, expiresAt e metadados da mídia. Baixe diretamente pela URL, sem credenciais de API; não é necessário solicitar um ticket de download. Campos monetários como cost são strings decimais sem $.

Um callback por tarefa, enviado quando estiver pronto

Cada tarefa recebe um evento de callback. O evento de uma tarefa malsucedida é montado no momento da falha. O de uma tarefa bem-sucedida é montado depois que os arquivos do resultado são copiados para nosso armazenamento, de modo que cada arquivo normalmente tenha uma url. As novas tentativas reenviam o mesmo evento; apenas os links temporários de mídia são atualizados.

Uma informação ainda pode mudar depois, e isso não dispara outro callback:

  • data.settled é false → a cobrança final ainda não foi definida. Consulte recordInfo mais tarde para obter o cost definitivo.

No caso raro em que um arquivo do resultado não pode ser armazenado, o callback ainda chega: esse arquivo tem unavailable: true e nenhuma url. Se nenhum arquivo da tarefa puder ser armazenado, ela não será cobrada — cost será "0".

Um callback com state: "succeeded", settled: true e uma url para cada arquivo está completo: você pode usá-lo sem outra consulta.

Em v2, code é sempre 200, inclusive para tarefas que falharam

Nunca use code para decidir se a tarefa foi bem-sucedida

O code do envelope descreve se esta entrega está no formato correto, não se a geração funcionou. Um callback de uma tarefa que falhou também contém 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"
  }
}

Um código que executa if (body.code === 200) markSucceeded() marca todas as tarefas malsucedidas como bem-sucedidas, sem nenhum indício aparente de problema. O sucesso ou a falha está em data.state; o motivo está em data.errorCode e data.errorMessage. Um callback não possui um cabeçalho de solicitação original para orientar o idioma, por isso errorMessage segue a configuração de idioma da conta e usa inglês por padrão; veja Idioma das mensagens de erro.

Isso corresponde a recordInfo, que também responde 200 quando a tarefa descrita falhou. Os dois canais concordam deliberadamente nesse ponto — uma divergência recriaria exatamente o problema de “mesmos dados, duas formas de ler” que esse formato foi projetado para eliminar.

Em v2, request_id é o ID da entrega — use-o como chave de idempotência

O request_id do envelope tem a aparência whd_…. Ele identifica este registro de entrega, não a tarefa:

  • Ele não muda entre novas tentativas. Novas tentativas automáticas e reenvios manuais reutilizam o mesmo evento de negócio e request_id. Apenas os campos temporários de mídia url e expiresAt são atualizados antes de cada envio; não elimine duplicatas em v2 pelo hash do corpo.
  • Isso transforma “já processei este callback?” em uma verificação simples: crie um índice único para ele no banco e retorne 2xx em caso de conflito.
  • O mesmo campo em uma resposta de recordInfo contém o ID da solicitação HTTP (req_…). O significado é o mesmo nos dois lugares: o identificador desta entrega individual.

Em v2, a tarefa está em data.taskId — ele responde a “qual tarefa?”. request_id responde a “qual entrega?”. v1 não tem request_id; elimine duplicatas com uma chave derivada de task_id, estado e digest do corpo.

O cabeçalho de versão acompanha os bytes

X-Webhook-Payload-Version informa com qual versão este corpo foi montado, não qual versão está configurada atualmente.

O payload de negócio e sua versão são fixados quando o evento entra na fila. Uma nova tentativa atualiza apenas as URLs temporárias de mídia e assina o corpo exato que envia; ela nunca o substitui pelo retrato mais recente da tarefa. O cabeçalho precisa acompanhar esse corpo: caso contrário, uma tentativa que passou horas na fila chegaria com um número de versão que contradiz seu próprio conteúdo — e é esse cabeçalho que orienta o fluxo do receptor.

v1 é uma camada de compatibilidade congelada. Não desenvolva para ela

A versão do payload é uma opção de compatibilidade de toda a plataforma, operada pela SpicyAPI; você não pode escolhê-la, e novas entregas usam a versão 2. Essa opção pode fixar o payload em 1, o formato legado: snake_case, sem envelope, campos planos no nível superior e ausência de input, settled e completedAt. Ela existe por um único motivo: impedir que integrações feitas para o formato antigo quebrem no dia em que o novo entrar em uso.

v1 não recebe novos campos. Daqui em diante, tudo é adicionado apenas a v2. Quando confirmarmos que ninguém mais usa v1, ela será removida junto com essa configuração.

Desenvolva novas integrações para v2. Entregas históricas ainda podem ser repetidas como v1, portanto não deduza a versão pelo formato do JSON. Leia X-Webhook-Payload-Version e escolha o fluxo como mostrado abaixo, tanto para verificar a assinatura quanto para interpretar o corpo.

A assinatura

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

Três partes unidas por .:

  1. o ID da tarefa, selecionado por X-Webhook-Payload-Version: task_id no nível superior para v1, data.taskId para v2 — nunca request_id;
  2. o valor de X-Webhook-Timestamp;
  3. o digest SHA-256 do corpo bruto da solicitação, em hexadecimal minúsculo.

O digest do corpo deve estar na assinatura — e seu verificador deve calculá-lo de verdade

Se você assinar apenas o ID da tarefa e o timestamp, qualquer pessoa que interceptar uma entrega legítima poderá combinar esses dois valores com um corpo criado por ela e reenviá-lo ao seu endpoint. A verificação pelo algoritmo publicado aprovará a assinatura.

Isso equivaleria a endossarmos uma mensagem falsa de “tarefa concluída, arquivo disponível aqui” — que você usaria para cobrar um cliente ou entregar um resultado. O digest não é opcional.

Calcule o digest dos bytes brutos

Não desserialize e serialize novamente antes. A ordem das chaves, os espaços e o escape de Unicode variam entre bibliotecas JSON; os bytes reconstruídos quase certamente não serão os que assinamos, e a verificação falhará sempre.

Como acessar o corpo bruto: express.raw() no Express, rawBody no Fastify, request.get_data() no Flask, request.body no Django e await request.body() no FastAPI.

Código de verificação

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

Teste seu verificador com curl

Salve o exemplo v2 succeeded de O payload como payload.json, defina SPICY_WEBHOOK_SECRET com o mesmo segredo usado pelo receptor e troque a URL no final pelo seu endpoint. Execute então:

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"

Resultado esperado: seu endpoint retorna um status 2xx. Agora altere um caractere de SPICY_WEBHOOK_SECRET e execute novamente — ele deve retornar 401. Se continuar retornando 2xx, seu receptor não está verificando assinaturas. O receptor do SDK acima também responde 404 para um ID de tarefa ausente em spicy_tasks; insira primeiro o taskId do exemplo nessa tabela ou teste com o código simples de verificação.

Endpoints no nível da conta

Um endpoint é uma URL registrada na conta junto com a lista dos eventos que deseja receber. Depois de registrado, todo evento correspondente é entregue a ele — você deixa de precisar enviar uma URL em cada tarefa.

Registre um endpoint

Pela 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"
  }'

Ainda não há uma tela no console para isso

Atualmente, os endpoints são gerenciados apenas pela API. Console → Webhooks gerencia o segredo de assinatura da conta usado por callBackUrl e lista as entregas; não possui uma página para registrar endpoints. Tudo nesta seção é feito pelas quatro chamadas mostradas aqui, e elas exigem um cookie de sessão do console — a mesma credencial usada pelo navegador, não uma chave sk-spicy-.

A resposta inclui secret, com aparência de whsec_…. Ele é exibido apenas uma vez. Nenhuma chamada posterior o retorna — nem a lista nem os detalhes. Se você o perder, a única opção é rotacioná-lo.

Uma conta pode ter 16 endpoints. A URL deve ser um endereço HTTPS público, verificado ao salvar e novamente na entrega, considerando o endereço para o qual ela realmente resolve.

Eventos que você pode assinar

EventoQuando é disparado
task.completedAtalho para os quatro eventos abaixo. Assine este, a menos que realmente precise distingui-los
task.succeededUma tarefa terminou
task.failedUma tarefa falhou; a reserva normalmente é liberada. Recusas de conteúdo podem ser cobradas quando a página do modelo informa expressamente essa exceção
task.canceledMantido para registros históricos — tarefas aceitas atualmente não podem ser canceladas
task.expiredUma tarefa ultrapassou o prazo; qualquer reserva do saldo é liberada

GET /console/v1/webhook-events retorna essa lista em tempo de execução, permitindo criar um seletor sem fixá-la no código.

Assine task.completed em vez de listar três dos quatro eventos

Listar succeeded, failed e canceled, esquecendo expired, é um erro comum; o sintoma é uma tarefa pela qual seu código espera para sempre. task.completed não pode ter essa lacuna.

Nomes de eventos desconhecidos são rejeitados ao salvar, não ignorados silenciosamente — um erro de digitação deve falhar no registro, não seis semanas depois, quando você perceber que certo tipo de evento nunca chegou. Curingas ("*") não são aceitos pelo mesmo motivo.

A entrega

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 é o mesmo registro de tarefa retornado por recordInfo, e representa um retrato do momento em que o evento foi criado, não o estado atual. Se processar uma entrega três dias depois, ainda lerá os valores daquele momento.

settled indica se cost é definitivo

Quando a liquidação é atrasada, a tarefa chega ao estado terminal antes do fechamento da cobrança; por isso, o evento é enviado com settled: false e cost ainda contendo a estimativa. Nenhum evento de correção é enviado depois. Se o valor exato importa, consulte recordInfo quando settled for false.

Verificação

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

Isso segue a especificação Standard Webhooks, portanto uma biblioteca feita para Stripe ou Replicate funciona com a alteração dos nomes dos cabeçalhos. Há duas diferenças em relação à assinatura de callBackUrl acima; errar qualquer uma produz silenciosamente uma assinatura que nunca é aprovada:

  1. O corpo em si é assinado, não seu digest. Não há uma etapa interna de sha256 aqui.
  2. A chave HMAC é a sequência de bytes obtida ao decodificar em base64 o trecho depois de whsec_ — não aquela string. Usar a string é o erro mais comum; nada lança um erro, a assinatura simplesmente fica sempre incorreta.
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 permanece igual em todas as tentativas de uma mensagem — use-o como chave de idempotência. webhook-timestamp é atualizado a cada tentativa, pois protege contra replay, não contra duplicatas.

Rotação do segredo sem interrupção

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

Durante o período de transição, cada entrega contém duas assinaturas separadas por espaço em um único cabeçalho; basta verificar uma delas. É para isso que serve o loop .some(…) acima. A ordem segura é: publicar um receptor que aceite ambas → rotacionar → configurar o novo segredo → deixar a janela terminar. Nada precisa ficar fora do ar.

A janela padrão é de 24 horas e a máxima é de 7 dias. Omita inteiramente o corpo para usar o padrão.

Quando um endpoint para de funcionar

As entregas são repetidas conforme o cronograma abaixo. Além disso:

  • Uma resposta 410 Gone desativa o endpoint imediatamente e descarta suas mensagens na fila. Continuar tentando um endereço cujo proprietário informou que não existe mais não ajuda ninguém. GET /console/v1/webhook-endpoints/{id} passa a informar status: "disabled" com disabledReason: "gone_410".
  • Qualquer outra falha incrementa consecutiveFailures no endpoint, também retornado pela mesma chamada. Atualmente, nenhuma desativação automática ocorre nesses casos.

Um endpoint com problemas continua assinando eventos

A desativação automática após dias de falha contínua ainda não está implementada. Um endpoint indisponível, mas que responde com algo diferente de 410, continua recebendo todos os eventos e consumindo todas as tentativas de cada um. Consulte consecutiveFailures por conta própria e aplique PATCH com {"enabled": false} ao endpoint enquanto o corrige.

balance.low e webhook.endpoint.disabled também ainda não estão disponíveis, por isso não aparecem na tabela acima.

Reativar um endpoint zera seus contadores de falhas, para que um endpoint recém-corrigido não carregue falhas antigas na próxima instabilidade.

Exclusão de um endpoint

A exclusão interrompe entregas futuras. Mensagens já na fila mantêm seu histórico — você ainda pode ver como terminaram —, mas sem o endpoint elas não podem mais ser assinadas; por isso terminam como falhas, em vez de desaparecerem do registro de entregas.

Como responder

Qualquer status 2xx indica recebimento. Qualquer outro status, falha de conexão ou tempo superior a 15 segundos conta como falha e dispara uma nova tentativa.

Confirme o recebimento antes de executar o trabalho — coloque o ID da tarefa na sua própria fila e retorne. Baixar, transcodificar e enviar notificações dentro da solicitação do callback apenas faz a entrega atingir o timeout e ser reenviada, enquanto seu lado já a processou parcialmente.

Cronograma de novas tentativas

Nova tentativaIntervalo após a tentativa anterior
110 segundos
230 segundos
32 minutos
410 minutos
530 minutos
62 horas
76 horas

Há oito tentativas automáticas, incluindo a primeira entrega. Os intervalos programados somam 8 horas, 42 minutos e 40 segundos; a duração das solicitações e o agendamento podem aumentar o tempo total. Depois de esgotadas, as tentativas automáticas param e a entrega aparece como failed em Console → Webhooks → Webhook deliveries. Ali, Retry a coloca na fila mais uma vez — até 6 tentativas manuais por minuto. Você não pode reenviar manualmente uma entrega que ainda está sendo repetida automaticamente, que já foi entregue ou cujo conteúdo foi removido pelas configurações de retenção — use recordInfo nesses casos.

Cada nova tentativa recebe uma assinatura nova

O timestamp é o do momento daquela entrega, não o da primeira tentativa. Caso contrário, uma tentativa enviada seis horas depois chegaria com um timestamp de seis horas atrás e seria rejeitada por qualquer janela de replay implementada corretamente — o mecanismo de novas tentativas falharia justamente quando você precisasse dele.

Para você, isso significa: não guarde em cache nem compare timestamps entre entregas. Valide cada um em relação ao horário atual.

Idempotência

Um callback da mesma tarefa pode chegar mais de uma vez: novas tentativas, duplicatas de rede e reenvios manuais. Nunca presuma entrega exatamente uma vez.

Em v2, elimine duplicatas pelo request_id do envelope (whd_…). Ele identifica o registro da entrega e permanece igual entre as tentativas, exatamente o que uma chave de idempotência precisa fazer. A implementação mais simples é um índice único no seu banco — em caso de conflito, retorne 2xx e continue.

v1 não tem request_id. Se ainda receber v1, derive uma chave de idempotência de task_id, estado e digest do corpo bruto. Não elimine duplicatas usando apenas o ID da tarefa, pois isso poderia suprimir no futuro um registro de estado terminal diferente para a mesma tarefa.

Restrições da URL de callback

  • Apenas https. URLs http:// são rejeitadas porque o corpo do callback contém seu prompt e links assinados do resultado.
  • Nenhuma porta explícita diferente de 443 ou 80; a porta padrão de uma URL https:// é aceita.
  • Não inclua usuário nem senha na URL.
  • O nome de host deve resolver, e todos os endereços resolvidos devem ser públicos. Loopback, redes privadas, endereços link-local e endereços de metadados de nuvem são rejeitados.
  • Também verificamos novamente o IP resolvido ao conectar, bloqueando o caminho de ataque por DNS rebinding.

Valores inválidos de callBackUrl na criação de tarefas, no streaming ou nas estimativas retornam HTTP 400 com código de negócio code: 400, msg: "Invalid callback URL" e um request_id. A resposta não repete a URL enviada, nome de host, endereço IP nem diagnósticos de DNS.

As duas verificações impedem que alguém use nossos servidores como ponte para uma rede privada. O efeito colateral é que http://localhost:3000 não funciona no desenvolvimento local — use um túnel, como ngrok ou Cloudflare Tunnel, para obter um endereço HTTPS público, ou consulte o status enquanto desenvolve localmente.

Nesta página