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://localhoste endereços dentro da rede da sua casa ou escritório não funcionam. Ao desenvolver no seu computador, consulte porrecordInfoou 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
callBackUrlem 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 tarefa | Endpoint no nível da conta | |
|---|---|---|
| Onde configurar | Um campo em cada createTask | Uma vez, pela API — ainda não há uma tela no console |
| O que entrega | O estado terminal daquela tarefa | Todos os tipos de evento assinados, para todas as tarefas |
| Cabeçalhos | X-Webhook-* | webhook-id / webhook-timestamp / webhook-signature |
| Corpo | O envelope {code, msg, data}, igual ao de recordInfo | Um objeto de evento sem envelope, com id, type e data.object |
| Segredo de assinatura | Um por conta, em Webhook signing key | Um por endpoint, exibido uma vez na criação ou rotação |
| Rotação da chave sem interrupção | Não — o segredo antigo deixa de verificar imediatamente | Sim — 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:
- Abra Console → Webhooks.
- No cartão Webhook signing key, clique em Generate, se aparecer Not set, ou em Rotate, se aparecer Configured.
- Copie o valor diretamente para
SPICY_WEBHOOK_SECRETno 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.
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çalho | Observações |
|---|---|
X-Webhook-Timestamp | Segundos Unix desta entrega. Usado para proteção contra replay |
X-Webhook-Signature | HMAC-SHA256 codificado em base64 |
X-Webhook-Payload-Version | Versã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-Agent | Sempre 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.
{
"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. ConsulterecordInfomais tarde para obter ocostdefinitivo.
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:
{
"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ídiaurleexpiresAtsã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
recordInfoconté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 .:
- o ID da tarefa, selecionado por
X-Webhook-Payload-Version:task_idno nível superior para v1,data.taskIdpara v2 — nuncarequest_id; - o valor de
X-Webhook-Timestamp; - 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
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);
}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)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:
# 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
| Evento | Quando é disparado |
|---|---|
task.completed | Atalho para os quatro eventos abaixo. Assine este, a menos que realmente precise distingui-los |
task.succeeded | Uma tarefa terminou |
task.failed | Uma 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.canceled | Mantido para registros históricos — tarefas aceitas atualmente não podem ser canceladas |
task.expired | Uma 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:
- O corpo em si é assinado, não seu digest. Não há uma etapa interna de
sha256aqui. - 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.
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 Gonedesativa 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 informarstatus: "disabled"comdisabledReason: "gone_410". - Qualquer outra falha incrementa
consecutiveFailuresno 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 tentativa | Intervalo após a tentativa anterior |
|---|---|
| 1 | 10 segundos |
| 2 | 30 segundos |
| 3 | 2 minutos |
| 4 | 10 minutos |
| 5 | 30 minutos |
| 6 | 2 horas |
| 7 | 6 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. URLshttp://são rejeitadas porque o corpo do callback contém seu prompt e links assinados do resultado. - Nenhuma porta explícita diferente de
443ou80; a porta padrão de uma URLhttps://é 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.

