Webhooks: cuerpos y firma
La guía completa del receptor: todos los eventos, el cuerpo, la verificación de la firma, los reintentos y los secretos.
Esto es todo lo que necesita un receptor una vez que has registrado un endpoint en la consola: qué llega, cómo comprobar que viene de Croombit y cómo tratar los reintentos. Verifica la firma antes de fiarte de nada de la petición: la URL en sí es pública.
Cómo es una entrega
Cada entrega es un POST HTTP con content-type: application/json y este cuerpo:
{
"id": "evt_…",
"type": "consent.granted",
"createdAt": "2026-09-08T12:00:00.000Z",
"environment": "LIVE",
"data": { "subjectId": "…", "purpose": "marketing_email", "state": "GRANTED",
"purposeVersion": 3, "recordId": "…", "source": "API", "receiptId": "rcpt_…" }
}id: el id del evento (evt_…). Es el mismo en cada reintento y en un reenvío, así que es la clave para eliminar duplicados.environment:TESToLIVE, según el endpoint. Es el único sitio donde se indica el entorno, así que compruébalo si una misma URL atiende a los dos.data: depende del evento; consulta la tabla de más abajo.
Cabeceras
x-croombit-event: el tipo de evento (el mismo quetypeen el cuerpo)x-croombit-delivery: el id de esta entrega a este endpointx-croombit-signature:t=<segundos unix>,v1=<hmac-sha256 en hex>. Mientras un secreto rotado está en su periodo de gracia hay dos valoresv1=(el secreto nuevo primero); acepta la petición si coincide cualquiera.constructWebhookEventya lo hace por ti.
Eventos
Suscribe cada endpoint a los eventos que necesites (la API rechaza cualquier nombre que no esté en esta lista). Un ? tras una clave indica que no siempre está presente.
| Evento | Cuándo se emite | Claves de `data` |
|---|---|---|
consent.granted | Primer registro GRANTED de un sujeto para un propósito. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.updated | Cualquier otro cambio de estado sobre un propósito que el sujeto ya tenía registrado (por ejemplo DENIED → GRANTED), incluido un DENIED como primer registro. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.withdrawn | Un registro WITHDRAWN. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.expired | Un consentimiento ha caducado por una política de caducidad del sitio. | subjectId, purpose, purposeVersion |
reconsent.required | Hay que volver a preguntar a un sujeto. Hoy solo se emite cuando caduca un consentimiento (reason: "expiry"). | subjectId, purpose, reason |
preference.updated | Se han escrito valores del centro de preferencias (opt-in simple, o tras confirmar un doble opt-in). | subjectId, fields, templateVersion |
preference.confirmation_requested | Se ha escrito la petición de un doble opt-in; nada está vigente hasta que se confirme. | subjectId, confirmationId, fields, expiresAt, channel |
preference.confirmed | Se ha confirmado un doble opt-in (se pulsó el enlace de confirmación). | subjectId, confirmationId, fields, templateVersion |
preference.confirmation_email_sent | Croombit envió el correo de doble opt-in (solo cuando se pasó to). | subjectId, confirmationId |
preference.confirmation_email_failed | Croombit no pudo enviar el correo de doble opt-in. | subjectId, confirmationId |
subject.merged | Se han fusionado dos sujetos (anónimo → identificado); los registros de la bóveda pasan al destino. No se emite cuando solo se reasigna un dispositivo. | sourceSubjectId, targetSubjectId, movedRecords |
subject.erased | Un sujeto ha sido destruido criptográficamente tras una solicitud de BORRADO. | subjectId |
cookie.scan_completed | Ha llegado un escaneo de cookies (la cola de revisión puede haber crecido). | scanRunId, trigger, url, cookiesFound, newUnclassified, llmBatched |
dsar.verification_requested | Se ha creado una solicitud de datos y hay que verificar la identidad de quien la hace. | dataRequestId, type, subjectId |
dsar.identity_verified | El solicitante ha verificado su identidad por correo. | dataRequestId, type, subjectId |
dsar.identity_document_submitted | El solicitante ha subido un documento de identidad para su revisión. | dataRequestId, type, subjectId |
dsar.identity_document_approved | El equipo ha aprobado el documento de identidad. | dataRequestId, type, subjectId |
dsar.identity_document_rejected | El equipo ha rechazado el documento de identidad. | dataRequestId, type, subjectId, reason |
consent.granted es el primer GRANTED de un sujeto para un propósito; consent.withdrawn es un registro WITHDRAWN; cualquier otra escritura es consent.updated, incluido un DENIED (sea o no el primero). data.source indica quién actuó: API (el propio sujeto) o IMPORT / MIGRATION (lo afirmaste tú desde tu servidor). Consulta Registrar y consultar el consentimiento.reconsent.required solo se emite cuando caduca un consentimiento (reason: "expiry", justo después de consent.expired). Publicar una versión de un propósito como Requires re-consent no lo emite: lee needsReconsent en GET /v1/subjects/{ref}/consent (o lanza una campaña de reconsentimiento). Un consentimiento escrito con su propio expiresAt también se lee como needsReconsent, pero no emite ningún evento.test.ping es un evento sintético que solo envía el botón Test de la consola. Se firma y entrega como uno real e ignora la lista de suscripciones del endpoint, pero su cuerpo no lleva environment y data es solo { "message": "…" }. No está en la tabla porque no se puede suscribir uno a él.Para descubrir los eventos en tiempo de ejecución en lugar de fijar una lista en tu código, llama a GET /v1/webhook-events con cualquier clave de API válida. Devuelve { events, delivery }: el mismo catálogo de arriba (type, description, data) más los nombres de las cabeceras, el esquema de firma, el timeout y el calendario de reintentos. En el SDK es tc.getWebhookEvents(); para agentes de IA, la herramienta MCP list_webhook_events.
Verificar la firma
La firma es el HMAC-SHA256 de la cadena "<t>.<cuerpo en bruto>", con el secreto whsec_… del endpoint como clave (la cadena completa, prefijo incluido), en hexadecimal. Cada intento, reintentos incluidos, se firma de nuevo, así que un reintento nunca falla por antigüedad.
JSON.stringify(req.body)), el orden de las claves, los espacios o el formato de los números pueden cambiar y una entrega legítima no pasará la verificación. En Express usa express.raw({ type: 'application/json' }), no express.json(), en esta ruta.Node.js / Next.js (recomendado)
constructWebhookEvent, de la entrada de servidor del SDK, verifica la firma (en tiempo constante, con una ventana anti-repetición de 300 s por defecto) y devuelve el evento ya interpretado y tipado. Lanza WebhookSignatureError si la firma falta, es incorrecta o ha caducado. Funciona en Node 18+, Deno, Bun y entornos edge (Web Crypto, sin dependencias). Si solo necesitas un sí/no, verifyWebhookSignature({ header, rawBody, secret }) devuelve un booleano.
// app/api/croombit/route.ts (Next.js App Router)
import { constructWebhookEvent, WebhookSignatureError } from '@croombit/sdk/server';
export async function POST(req: Request) {
const rawBody = await req.text(); // los bytes exactos — NO uses req.json()
let event;
try {
event = await constructWebhookEvent({
header: req.headers.get('x-croombit-signature'),
rawBody,
secret: process.env.TC_WEBHOOK_SECRET!, // whsec_…
});
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response('invalid signature', { status: 400 });
throw err;
}
// Idempotencia: el mismo event.id puede llegar más de una vez.
if (await alreadyProcessed(event.id)) return new Response('ok');
switch (event.type) {
case 'consent.withdrawn':
await suppressInEsp(event.data.subjectId as string, event.data.purpose as string);
break;
case 'subject.erased':
await deleteLocalCopies(event.data.subjectId as string);
break;
}
await markProcessed(event.id);
return new Response('ok'); // cualquier 2xx, en menos de 10 s
}Cualquier otro lenguaje
- Divide la cabecera por
,y leet(segundos unix) y todos losv1(hex): hay dos mientras un secreto rotado está en su periodo de gracia. - Rechaza la petición si
|now − t|supera tu tolerancia (300 s es un buen valor por defecto). - Calcula
HMAC-SHA256(secret, t + "." + rawBody)en hex minúsculas. - Compáralo con cada
v1en tiempo constante; la petición es auténtica si coincide alguno. Solo entonces interpreta el JSON.
import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
t, v1s = None, []
for kv in header.split(","):
k, _, v = kv.partition("=")
if k.strip() == "t":
try:
t = int(v)
except ValueError:
return False
elif k.strip() == "v1":
v1s.append(v.strip())
if t is None or not v1s or abs(time.time() - t) > tolerance:
return False
signed = str(t).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# un secreto rotado en su periodo de gracia añade un segundo v1: vale cualquiera
return any(hmac.compare_digest(expected, v) for v in v1s)import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(header string, rawBody []byte, secret string, tolerance time.Duration) bool {
var ts string
var v1s []string
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(kv, "="); ok {
switch strings.TrimSpace(k) {
case "t":
ts = strings.TrimSpace(v)
case "v1":
v1s = append(v1s, strings.TrimSpace(v))
}
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || len(v1s) == 0 {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
want := mac.Sum(nil)
// un secreto rotado en su periodo de gracia añade un segundo v1: vale cualquiera
for _, v1 := range v1s {
if got, err := hex.DecodeString(v1); err == nil && hmac.Equal(want, got) {
return true
}
}
return false
}Reintentos, timeouts y duplicados
Responde 2xx en menos de 10 s. Cualquier otra cosa (otro código, un timeout, una redirección) cuenta como fallo; las redirecciones no se siguen. El primer intento sale de inmediato y después hay hasta 8 reintentos: a 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h y 24 h, unos 2,8 días en total, suficiente para cubrir una caída de fin de semana. Después la entrega queda como fallida y puedes reenviarla desde la consola o la API.
Las entregas son duraderas: cada una se guarda antes de enviarse, así que un fallo por nuestra parte la retrasa pero nunca la pierde. La entrega es al menos una vez, por lo que tu handler debe ser idempotente: guarda el id evt_ de cada evento que hayas tratado y ignora los repetidos. Un timeout por tu parte puede significar que el evento se procesó y luego se reintentó. Haz el trabajo pesado después de responder (ponlo en una cola) y responde rápido.
No se garantiza que los eventos lleguen en orden. Si el orden importa (por ejemplo, consent.granted y luego consent.withdrawn), compara createdAt o lee el estado actual en GET /v1/subjects/{ref}/consent en lugar de fiarte del último evento que viste.
Secretos y rotación
- El secreto de firma
whsec_…se muestra una sola vez, al crear el endpoint. Guárdalo enseguida en tu gestor de secretos; no podemos volver a mostrarlo. - Rota desde la consola (Rotate secret en el endpoint), la herramienta MCP
rotate_webhook_secretoPOST /admin/sites/{siteId}/webhook-endpoints/{id}/rotate-secretcon{ "gracePeriodHours": 0–72 }. El secreto nuevo se muestra una sola vez; las suscripciones y el registro de entregas se conservan. - Cambio sin cortes: rota con un periodo de gracia (por ejemplo 24 h). Hasta que termine, cada entrega se firma con los dos secretos (dos valores
v1=, el nuevo primero), así que un receptor que aún tiene el antiguo sigue verificando mientras despliegas el nuevo. Después el antiguo deja de firmar solo. Si rotas otra vez durante una gracia, el secreto más antiguo se descarta. - Si se ha filtrado un secreto: rota con
gracePeriodHours: 0(el valor por defecto): el antiguo deja de valer al instante y las entregas que se reintenten después se firman solo con el nuevo. - Cada endpoint pertenece a un entorno. Usa un endpoint (y un secreto) distinto para
TESTy paraLIVE. - Los endpoints deben ser URLs
https://públicas. Se rechazan las direcciones privadas, de loopback y link-local (incluidos los metadatos de la nube), y no se siguen las redirecciones.