Docs

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: TEST o LIVE, 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 que type en el cuerpo)
  • x-croombit-delivery: el id de esta entrega a este endpoint
  • x-croombit-signature: t=<segundos unix>,v1=<hmac-sha256 en hex>. Mientras un secreto rotado está en su periodo de gracia hay dos valores v1= (el secreto nuevo primero); acepta la petición si coincide cualquiera. constructWebhookEvent ya 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.

EventoCuándo se emiteClaves de `data`
consent.grantedPrimer registro GRANTED de un sujeto para un propósito.subjectId, purpose, state, purposeVersion, recordId, source, receiptId?
consent.updatedCualquier 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.withdrawnUn registro WITHDRAWN.subjectId, purpose, state, purposeVersion, recordId, source, receiptId?
consent.expiredUn consentimiento ha caducado por una política de caducidad del sitio.subjectId, purpose, purposeVersion
reconsent.requiredHay que volver a preguntar a un sujeto. Hoy solo se emite cuando caduca un consentimiento (reason: "expiry").subjectId, purpose, reason
preference.updatedSe han escrito valores del centro de preferencias (opt-in simple, o tras confirmar un doble opt-in).subjectId, fields, templateVersion
preference.confirmation_requestedSe ha escrito la petición de un doble opt-in; nada está vigente hasta que se confirme.subjectId, confirmationId, fields, expiresAt, channel
preference.confirmedSe ha confirmado un doble opt-in (se pulsó el enlace de confirmación).subjectId, confirmationId, fields, templateVersion
preference.confirmation_email_sentCroombit envió el correo de doble opt-in (solo cuando se pasó to).subjectId, confirmationId
preference.confirmation_email_failedCroombit no pudo enviar el correo de doble opt-in.subjectId, confirmationId
subject.mergedSe 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.erasedUn sujeto ha sido destruido criptográficamente tras una solicitud de BORRADO.subjectId
cookie.scan_completedHa llegado un escaneo de cookies (la cola de revisión puede haber crecido).scanRunId, trigger, url, cookiesFound, newUnclassified, llmBatched
dsar.verification_requestedSe ha creado una solicitud de datos y hay que verificar la identidad de quien la hace.dataRequestId, type, subjectId
dsar.identity_verifiedEl solicitante ha verificado su identidad por correo.dataRequestId, type, subjectId
dsar.identity_document_submittedEl solicitante ha subido un documento de identidad para su revisión.dataRequestId, type, subjectId
dsar.identity_document_approvedEl equipo ha aprobado el documento de identidad.dataRequestId, type, subjectId
dsar.identity_document_rejectedEl equipo ha rechazado el documento de identidad.dataRequestId, type, subjectId, reason
Los eventos de consentimiento siguen al registro. 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.

Verifica exactamente los bytes que recibiste. Lee el cuerpo en bruto como texto antes de cualquier análisis JSON y firma sobre eso. Si lo interpretas y lo vuelves a serializar (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

  1. Divide la cabecera por , y lee t (segundos unix) y todos los v1 (hex): hay dos mientras un secreto rotado está en su periodo de gracia.
  2. Rechaza la petición si |now − t| supera tu tolerancia (300 s es un buen valor por defecto).
  3. Calcula HMAC-SHA256(secret, t + "." + rawBody) en hex minúsculas.
  4. Compáralo con cada v1 en 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_secret o POST /admin/sites/{siteId}/webhook-endpoints/{id}/rotate-secret con { "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 TEST y para LIVE.
  • 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.