Webhooks
Distingue los cuerpos de webhook v1 y v2, selecciona el ID de tarea correcto y verifica firmas con ejemplos de Node.js, Python y curl.
Un webhook o callback es una solicitud que nosotros enviamos a tu servidor cuando termina una tarea, para evitar consultas repetidas. Envía callBackUrl a createTask y realizamos una entrega POST cuando alcanza un estado final. Para una tarea correcta esperamos a que los archivos estén listos para descargar, de modo que el callback incluye enlaces utilizables. Las entregas fallidas siguen el calendario de reintentos indicado abajo.
Solo se envían callbacks en estados finales. No hay notificación de queued → running.
Antes de empezar
- Tu servidor necesita una dirección HTTPS pública. Nuestros servidores deben llegar a ella por internet y debe empezar por
https://.http://localhosty las direcciones de tu red doméstica u oficina no funcionan. Durante el desarrollo en tu computadora, usa sondeo conrecordInfoo una herramienta de túnel, como ngrok o Cloudflare Tunnel, que proporcione una dirección HTTPS pública temporal. Las reglas completas están en Restricciones de URL de callback. - Hay dos formas de recibir eventos y pueden coexistir. Enviar
callBackUrlen una tarea es lo más rápido; registrar un endpoint en tu cuenta cubre todas las tareas sin repetir la URL. Consulta Cuál elegir. - No hay un botón para enviar un evento de prueba. Prueba primero la verificación de firma con el script de curl siguiente, crea después una tarea real económica con
callBackUrl, cobrada como cualquier otra, y revisa Consola → Webhooks → Entregas de webhook, donde cada entrega aparece con su estado.
Cuál elegir
callBackUrl en una tarea | Endpoint de cuenta | |
|---|---|---|
| Dónde se configura | Un campo en cada createTask | Una vez mediante la API; todavía no hay pantalla en consola |
| Qué entrega | El estado final de esa tarea | Todos los tipos de eventos a los que te suscribas, para todas las tareas |
| Encabezados | X-Webhook-* | webhook-id / webhook-timestamp / webhook-signature |
| Cuerpo | El formato {code, msg, data}, igual que recordInfo | Un objeto de evento sin envoltorio, con id, type y data.object |
| Secreto de firma | Uno por cuenta, en Clave de firma de webhook | Uno por endpoint, mostrado una vez al crear o rotar |
| Rotación sin interrupción | No: el secreto anterior deja de verificar de inmediato | Sí: se envían ambas firmas durante el periodo de gracia |
Ninguno sustituye al otro. Una tarea con callBackUrl y una cuenta con una suscripción coincidente genera dos entregas, una de cada formato. Es una configuración normal, no un error.
Si estás integrando ahora y no tienes una razón clara para elegir una opción, registra un endpoint de cuenta: escribes la URL una vez, puedes rotar el secreto sin un cambio simultáneo en todos los receptores y el cuerpo tiene el formato habitual de las bibliotecas de webhooks.
El resto de la página describe las entregas de callBackUrl salvo que se indique lo contrario. Endpoints de cuenta explica las diferencias.
Configura primero un secreto de firma
Cada entrega se firma con el secreto de firma de tu cuenta para que tu servidor compruebe que procede de SpicyAPI. Todas las claves de API de la cuenta comparten un secreto.
SpicyAPI muestra el secreto una sola vez
Las cuentas suelen empezar con un secreto configurado, pero nunca has visto su valor; debes crear uno que puedas copiar:
- Abre Consola → Webhooks.
- En Clave de firma de webhook, pulsa Generar si aparece Sin configurar o Rotar si aparece Configurada.
- Copia el valor directamente a
SPICY_WEBHOOK_SECRETen tu servidor y pulsa Ya lo guardé. No se puede volver a mostrar; si lo pierdes, rota otra vez.
La rotación es inmediata: el secreto anterior deja de verificar y las entregas pendientes de reintento se firman con el nuevo. Actualiza todos los receptores justo después de rotar.
Sin un secreto no se puede firmar la entrega: cada intento cuenta como fallido y sigue el calendario de reintentos. Consulta recordInfo para cualquier tarea cuyo callback se haya perdido mientras no estaba disponible el secreto.
Empieza con el verificador del SDK
Para un receptor v2 nuevo, usa verifyWebhook de @spicyapi/sdk/webhooks. Comprueba la firma del cuerpo original y la ventana de cinco minutos de la fecha, y devuelve taskId y deliveryId. Esta función utiliza crypto de Node.js; mantenla en tu servidor.
El ejemplo usa Express y PostgreSQL (npm install @spicyapi/sdk express pg). Configura la base de datos con las variables habituales PG* y asigna a SPICY_WEBHOOK_SECRET el secreto de la consola. Sirve la ruta detrás de HTTPS. El cuerpo contiene tu input completo, incluido el prompt, por lo que el ejemplo permite hasta 10 MB; ajusta tu límite según corresponda. Crea una vez estas dos tablas:
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()
);Guarda cada taskId devuelto por tus envíos en spicy_tasks. El receptor solo acepta eventos de esas tareas; si un callback excepcionalmente rápido llega antes de insertar el registro, su respuesta fuera de 2xx permite reintentar la entrega. En una aplicación compartida, aplica también la regla de propiedad de tareas de tu organización o cliente.
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");Registra esta ruta de cuerpo original antes de un middleware global express.json(). Devuelve 2xx solo después de confirmar la escritura en la bandeja de entrada y deja que un proceso en segundo plano trate el evento guardado. La clave primaria hace que las entregas duplicadas no produzcan cambios. Valida data.state y data.settled antes de considerar un resultado correcto o un costo definitivo. Si venció una URL multimedia guardada, consulta de nuevo la tarea mientras se conserve el resultado.
Este ejemplo de integración nueva acepta v2. Para receptores históricos v1, conserva la verificación consciente de versiones siguiente; la misma función del SDK admite payloadVersion: 1, pero v1 no tiene deliveryId.
La solicitud
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| Encabezado | Notas |
|---|---|
X-Webhook-Timestamp | Segundos Unix de esta entrega. Se usa para proteger frente a reenvíos maliciosos |
X-Webhook-Signature | HMAC-SHA256 codificado en base64 |
X-Webhook-Payload-Version | Versión del formato del cuerpo. Las integraciones nuevas usan 2; las entregas históricas todavía pueden usar 1. Basa siempre las condiciones en este encabezado. Consulta El contenido |
User-Agent | Siempre SpicyAPI-Webhook/1 |
No seguimos redirecciones: una respuesta 3xx cuenta como entrega fallida. El tiempo de espera es de 15 segundos.
El contenido
v2 coincide con recordInfo; v1 es el formato plano anterior
Un callback de versión 2 tiene el mismo formato que GET /jobs/recordInfo: {code, msg, data, request_id} y los mismos campos camelCase dentro de data. La versión 1 no tiene envoltorio y coloca los campos snake_case de la tarea en el nivel superior.
Crea las integraciones nuevas para v2, pero haz que el punto de verificación lea X-Webhook-Payload-Version: toma el ID de task_id superior en v1 y de data.taskId en v2.
Los ejemplos usan MODEL_ID_FROM_CATALOG para representar el ID exacto elegido originalmente del catálogo actualizado y autenticado. No es un 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"
}
}El ejemplo anterior es v2. La referencia de campos de data está en Tareas asíncronas; gobierna tanto recordInfo como los callbacks v2 y no se repite aquí.
Igual que en recordInfo, los archivos listos de data.output.assets[] incluyen url, expiresAt y metadatos multimedia. Recupera la URL directamente sin credenciales de API; no necesitas solicitar un ticket de descarga. Los importes como cost son cadenas decimales sin $.
Un callback por tarea, enviado cuando está listo
Cada tarea obtiene un evento de callback. El de una tarea fallida se construye al fallar. El de una correcta se construye cuando sus archivos se han copiado a nuestro almacenamiento, por lo que normalmente cada archivo tiene url. Los reintentos envían el mismo evento; solo se renuevan los enlaces temporales.
Todavía puede cambiar un dato después, y no genera otro callback:
data.settledesfalse: todavía no se decidió el cargo final. Consulta despuésrecordInfopara conocer elcostdefinitivo.
En el caso poco frecuente de que un archivo no se pueda almacenar, el callback llega igualmente: ese archivo tiene unavailable: true y no tiene url. Si no se pudo almacenar ninguno, no se cobra la tarea: cost es "0".
Un callback con state: "succeeded", settled: true y una url en cada archivo está completo: puedes usarlo sin otra consulta.
En v2, code siempre es 200, también en tareas fallidas
Nunca uses code para decidir si la tarea terminó correctamente
El code del formato de respuesta describe si esta entrega está bien formada, no si funcionó la generación. Un callback de tarea fallida también incluye 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"
}
}Un código como if (body.code === 200) markSucceeded() marca todas las tareas fallidas como correctas sin parecer averiado. El éxito o fallo está en data.state; el motivo, en data.errorCode y data.errorMessage. Un callback no tiene un encabezado de solicitud para elegir idioma, por lo que errorMessage sigue el ajuste de cuenta y usa inglés de forma predeterminada; consulta Idioma de los mensajes de error.
Coincide con recordInfo, que también devuelve 200 al describir una tarea fallida. Las dos vías coinciden deliberadamente: discrepar recrearía el problema de tener los mismos datos y dos formas de leerlos que este formato resuelve.
En v2, request_id es el ID de entrega: úsalo para idempotencia
El request_id tiene el formato whd_…. Identifica este registro de entrega, no la tarea:
- No cambia entre reintentos. Los automáticos y los reenvíos manuales reutilizan el evento y su
request_id. Solo se renuevan los campos temporalesurlyexpiresAtantes de enviar; no dedupliques v2 mediante un hash del cuerpo. - Así, saber si ya procesaste el callback se resuelve con un índice único en tu base de datos y una respuesta 2xx ante un conflicto.
- En una respuesta de
recordInfo, ese campo contiene el ID de solicitud HTTP (req_…). El significado es el mismo: identificador de esta entrega concreta.
En v2, la tarea está en data.taskId, que responde «qué tarea»; request_id responde «qué entrega». v1 no tiene request_id; deduplícalo con una clave derivada de task_id, estado y hash del cuerpo.
El encabezado de versión corresponde a los bytes
X-Webhook-Payload-Version informa con qué versión se construyó este cuerpo, no qué versión está configurada actualmente.
El contenido de negocio y su versión se fijan al encolar. Un reintento solo renueva URL temporales y firma el cuerpo exacto que envía; nunca lo sustituye por el estado más reciente. El encabezado debe acompañarlos: si no, un reintento encolado durante horas podría llegar con una versión que contradice su contenido, cuando ese encabezado es la base de tus condiciones.
v1 es una capa de compatibilidad congelada. No desarrolles para ella
La versión es un ajuste global de compatibilidad gestionado por SpicyAPI; no puedes elegirla y las entregas nuevas usan la versión 2. Puede fijar el contenido en 1, el formato anterior: snake_case, sin envoltorio, campos planos superiores y sin input, settled ni completedAt. Solo existe para que las integraciones anteriores no fallen al introducir el nuevo formato.
v1 no recibe campos nuevos. Todo lo añadido desde ahora llega únicamente a v2. Cuando confirmemos que nadie sigue en v1, eliminaremos la versión y el ajuste.
Desarrolla las integraciones nuevas para v2. Las entregas históricas pueden reintentarse como v1; no deduzcas la versión del formato JSON. Lee X-Webhook-Payload-Version y aplica las condiciones siguientes tanto para firmas como para interpretación del cuerpo.
La firma
signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) )Tres partes unidas por .:
- El ID de tarea, según
X-Webhook-Payload-Version:task_idsuperior en v1,data.taskIden v2; nuncarequest_id. - El valor de
X-Webhook-Timestamp. - El hash SHA-256 del cuerpo original, en hexadecimal minúsculo.
El hash del cuerpo debe estar en la firma y tu verificador debe calcularlo
Si solo firmas el ID y la fecha, cualquiera que intercepte una entrega legítima puede combinar esos valores con su propio cuerpo y reenviarlo a tu endpoint. Al verificar con ese algoritmo incompleto, la firma coincidiría.
Eso equivaldría a respaldar un mensaje falsificado de «tarea terminada, archivo aquí», que podría llevarte a cobrar a un cliente o entregar un resultado. El hash no es opcional.
Calcula el hash de los bytes originales
No deserialices y vuelvas a serializar primero. El orden de las claves, los espacios y el escape Unicode varían entre bibliotecas JSON; los bytes resultantes casi nunca serán los firmados y la verificación fallará siempre.
Obtén el cuerpo original con express.raw() en Express, rawBody en Fastify, request.get_data() en Flask, request.body en Django o await request.body() en FastAPI.
Código de verificación
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)
}Prueba el verificador con curl
Guarda el ejemplo v2 succeeded de El contenido como payload.json, asigna a SPICY_WEBHOOK_SECRET el mismo secreto de tu receptor y cambia la URL final por tu endpoint. Después ejecuta:
# 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: tu endpoint devuelve 2xx. Cambia un carácter de SPICY_WEBHOOK_SECRET y repite: debe devolver 401. Si todavía devuelve 2xx, no comprueba firmas. El receptor del SDK anterior también devuelve 404 si no tiene el ID en spicy_tasks; inserta primero el taskId de ejemplo o prueba con el código simple de verificación.
Endpoints de cuenta
Un endpoint es una URL registrada en tu cuenta junto con los eventos que quiere recibir. Cuando existe, cada evento coincidente se entrega allí; ya no necesitas enviar una URL en cada tarea.
Registrar un endpoint
Mediante la 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"
}'Todavía no hay una pantalla en la consola
Actualmente los endpoints se gestionan solo mediante API. Consola → Webhooks administra el secreto de firma de cuenta usado por callBackUrl y muestra entregas; no tiene una página para registrar endpoints. Estas operaciones usan las cuatro llamadas mostradas aquí y requieren una cookie de sesión de consola: la misma credencial de tu navegador, no una clave sk-spicy-.
La respuesta contiene secret, con formato whsec_…. Se muestra una sola vez. Ninguna llamada posterior lo devuelve, ni la lista ni el detalle. Si lo pierdes, debes rotarlo.
Una cuenta puede tener 16 endpoints. La URL debe ser HTTPS pública y se comprueba al guardar y de nuevo al entregar, según la dirección real a la que resuelve.
Eventos disponibles
| Evento | Cuándo se envía |
|---|---|
task.completed | Agrupa los cuatro siguientes. Suscríbete a él salvo que necesites distinguirlos |
task.succeeded | Terminó una tarea |
task.failed | Falló una tarea; normalmente se libera la retención. Los rechazos de contenido pueden cobrarse cuando la página del modelo declara explícitamente esta excepción |
task.canceled | Conservado para registros históricos: las tareas aceptadas hoy no pueden cancelarse |
task.expired | Una tarea superó su plazo; se libera cualquier retención del saldo |
GET /console/v1/webhook-events devuelve esta lista durante la ejecución, para construir un selector sin fijarla en el código.
Suscríbete a task.completed en lugar de enumerar tres de cuatro estados
Enumerar succeeded, failed y canceled y olvidar expired es un error habitual: tu código espera una tarea para siempre. task.completed evita ese hueco.
Los nombres desconocidos se rechazan al guardar en lugar de ignorarse: un error de escritura debe fallar al registrar, no seis semanas después al descubrir que nunca llegó un tipo de evento. Por la misma razón, no se admiten comodines ("*").
La 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 es el mismo registro que devuelve recordInfo, pero una instantánea tomada al crear el evento, no el estado actual. Si lo procesas tres días tarde, sigues leyendo los valores de aquel momento.
settled indica si cost es definitivo
En la liquidación retrasada, la tarea alcanza un estado final antes de cerrar la facturación, por lo que el evento sale con settled: false y cost aún contiene la estimación. No se envía un evento correctivo. Si necesitas el importe exacto, consulta recordInfo cuando settled sea false.
Verificar la firma
signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Sigue la especificación Standard Webhooks, por lo que una biblioteca para Stripe o Replicate funciona cambiando únicamente los nombres de encabezados. Hay dos diferencias respecto a la firma de callBackUrl; equivocarse en cualquiera genera silenciosamente una firma que nunca verifica:
- Se firma el propio cuerpo, no su hash. Aquí no hay un paso interno
sha256. - La clave HMAC son los bytes que obtienes al decodificar en base64 lo que sigue a
whsec_, no esa cadena. Usar la cadena es el error más frecuente; no produce un error, solo una firma siempre incorrecta.
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 no cambia entre reintentos del mismo mensaje: úsalo como clave de idempotencia. webhook-timestamp se renueva en cada intento porque protege frente a reenvíos maliciosos, no frente a duplicados.
Rotar un secreto sin interrupción
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
-H 'Content-Type: application/json' \
-d '{"gracePeriodSeconds": 86400}'Durante el periodo de gracia, cada entrega lleva dos firmas separadas por espacios en un encabezado y basta con verificar una. Para eso sirve el bucle .some(…). El orden seguro es: desplegar un receptor que acepte ambas → rotar → configurar el nuevo secreto → dejar que termine el periodo. No hay que interrumpir el servicio.
El periodo predeterminado es de 24 horas y el máximo, de 7 días. Omite completamente el cuerpo para usar el predeterminado.
Cuando deja de funcionar un endpoint
Las entregas se reintentan según el calendario siguiente. Además:
- Una respuesta
410 Gonedeshabilita el endpoint inmediatamente y descarta sus mensajes en cola. No ayuda seguir enviando a una dirección cuyo propietario indica que ya no existe.GET /console/v1/webhook-endpoints/{id}informa entoncesstatus: "disabled"condisabledReason: "gone_410". - Cualquier otro fallo incrementa
consecutiveFailures, que devuelve la misma llamada. Actualmente no se deshabilita nada automáticamente por esos fallos.
Un endpoint averiado sigue suscrito
La deshabilitación automática tras días de fallos sostenidos todavía no está implementada. Un endpoint caído que responde algo distinto de 410 sigue recibiendo cada evento y consume todos los reintentos de cada uno. Consulta tú consecutiveFailures y envía PATCH con {"enabled": false} mientras lo reparas.
balance.low y webhook.endpoint.disabled tampoco están disponibles todavía; por eso no aparecen en la tabla.
Volver a habilitarlo borra sus contadores de fallos, para que un endpoint reparado no arrastre fallos antiguos a su siguiente incidente.
Eliminar un endpoint
Eliminarlo detiene futuras entregas. Los mensajes ya encolados conservan su historial y puedes ver cómo terminaron; sin el endpoint ya no se pueden firmar, así que terminan fallidos en vez de desaparecer del registro de entregas.
Qué responder
Cualquier 2xx significa recibido. Otro estado, un fallo de conexión o más de 15 segundos cuenta como fallo y genera un reintento.
Confirma antes de trabajar: añade el ID a tu cola y responde. Descargar, transcodificar o notificar dentro del callback agota el tiempo de entrega y provoca un reenvío cuando tu sistema ya lo procesó a medias.
Calendario de reintentos
| Reintento | Espera desde el intento anterior |
|---|---|
| 1 | 10 segundos |
| 2 | 30 segundos |
| 3 | 2 minutos |
| 4 | 10 minutos |
| 5 | 30 minutos |
| 6 | 2 horas |
| 7 | 6 horas |
Hay ocho intentos automáticos, incluida la primera entrega. Los intervalos suman 8 horas, 42 minutos y 40 segundos; la duración de las solicitudes y la programación pueden ampliar el tiempo transcurrido. Al agotarlos, se detienen los reintentos automáticos y la entrega aparece como fallida en Consola → Webhooks → Entregas de webhook. Allí, Reintentar vuelve a encolarla, con un máximo de 6 reintentos manuales por minuto. No puedes reenviar manualmente una entrega que sigue en reintento automático, una ya entregada ni una cuyo contenido se eliminó por retención: usa recordInfo para esos casos.
Cada reintento se firma de nuevo
La fecha corresponde al momento de esa entrega, no al primer intento. De lo contrario, un reintento seis horas después llegaría con una fecha seis horas antigua y cualquier ventana antirreenvío bien implementada lo rechazaría justo cuando lo necesitas.
Para ti significa que no debes guardar ni comparar fechas entre entregas. Valida cada una contra la hora actual.
Idempotencia
Un callback de la misma tarea puede llegar más de una vez, por reintentos, duplicados de red o reenvíos manuales. Nunca supongas entrega exactamente una vez.
Para v2, deduplica mediante request_id (whd_…). Identifica el registro de entrega y permanece igual entre reintentos, como debe hacerlo una clave de idempotencia. La implementación más sencilla es un índice único en tu base de datos; ante un conflicto devuelve 2xx y continúa.
v1 no tiene request_id. Si todavía lo recibes, deriva una clave de task_id, estado y hash del cuerpo original. No dedupliques solo por ID de tarea: podrías suprimir en el futuro otro registro distinto de estado final para esa tarea.
Restricciones de URL de callback
- Solo
https. Las URLhttp://se rechazan porque el cuerpo contiene tu prompt y enlaces firmados de resultados. - No uses un puerto explícito distinto de
443u80; el puerto predeterminado de una URLhttps://es válido. - No incluyas usuario ni contraseña en la URL.
- El host debe resolver y todas sus direcciones deben ser públicas. Se rechazan loopback, redes privadas, direcciones de enlace local y direcciones de metadatos de nube.
- Comprobamos la IP resuelta de nuevo al conectar para impedir ataques de DNS rebinding.
Un callBackUrl no válido en creación, streaming o cotización devuelve HTTP 400 con code: 400 de negocio, msg: "Invalid callback URL" y un request_id. La respuesta no repite la URL, host, IP ni diagnósticos DNS enviados.
Ambas comprobaciones impiden usar nuestros servidores como salto hacia una red privada. Como consecuencia, http://localhost:3000 no funciona en desarrollo local: usa un túnel, como ngrok o Cloudflare Tunnel, para obtener HTTPS público, o utiliza sondeo mientras desarrollas.

