Handlers de webhooks que sobreviven a producción
Guías

Handlers de webhooks que sobreviven a producción

Firmas a través de una rotación de secreto, una escalera de ocho intentos y un handler que se puede ejecutar dos veces - la checklist de los webhooks de cmssy.

E
Equipo Cmssy
5 min read

Handlers de webhooks que sobreviven a producción

Qué es una entrega de webhook de cmssy, cómo verificar su firma, qué hace la escalera de reintentos y cómo escribir un handler que se pueda ejecutar dos veces sin riesgo.

Un handler de webhooks parece diez líneas de código hasta la primera vez que contabiliza un cobro dos veces, pierde la mitad de sus entregas durante una rotación de secreto o deja de recibir nada en silencio tras un deploy fallido. Nada de eso es exótico. Es lo que pasa cuando un handler supone que una entrega llega una sola vez, en orden y firmada con el único secreto que conoce.

Este artículo es la checklist de un handler de webhooks de cmssy que sobrevive a producción: qué es una entrega, cómo verificarla, qué hace la escalera de reintentos y cómo escribir un handler que se pueda ejecutar dos veces sin riesgo.

Qué es una entrega

Cada entrega es un POST con un cuerpo JSON:

{
  "id": "0b6f2c1e-…",
  "event": "order.paid",
  "createdAt": "2026-10-26T14:21:19.846Z",
  "data": { "workspaceId": "…", "order": { … } }
}

El id es el id de la entrega. Se mantiene igual en cada reintento de esa entrega y también llega en la cabecera x-cmssy-webhook-id, junto a x-cmssy-event y x-cmssy-signature. Reenviar desde el admin o con redeliver_webhook_delivery crea una entrega nueva con un id nuevo, así que un handler que deduplica por id trata una repetición manual como nueva - que suele ser lo que quieres cuando repites a propósito.

Los payloads son ligeros: los eventos de contenido llevan referencias, no contenido, y form.submitted lleva el id del envío, no las respuestas. Los eventos de comercio llevan la instantánea del pedido que necesita tu integración - estado, totales, tratamiento fiscal, comprador, paquetes y facturas.

Verifica la firma

La cabecera x-cmssy-signature: t es una marca de tiempo en milisegundos, v1 es un HMAC-SHA256 de la marca de tiempo, un punto y el cuerpo sin procesar con el secreto del endpoint como clave, y durante una rotación de secreto aparece un segundo v1.
Una marca de tiempo, una firma por cada secreto activo.

La cabecera tiene la forma t=<unix-ms>,v1=<hex>. El hex es un HMAC-SHA256 sobre <t>.<raw body>, con el secreto del endpoint como clave. Tres detalles hacen tropezar a los verificadores escritos a mano:

  • La rotación envía dos firmas. Mientras se rota un secreto, la cabecera lleva un v1 por cada secreto activo. Un verificador que solo comprueba el último rechaza todas las entregas firmadas con el otro mientras dure la rotación.
  • El cuerpo debe ir sin procesar. Parsea el JSON, vuelve a serializarlo y los bytes cambian - orden de claves, espacios, escapes unicode. Verifica await request.text() y después parsea.
  • Las marcas de tiempo antiguas deben fallar. Sin una ventana de tolerancia, una entrega capturada puede reproducirse para siempre.

El helper del SDK hace las tres cosas: lee cada v1, compara en tiempo constante, rechaza una marca de tiempo de más de cinco minutos y devuelve el evento tipado. Viene en @cmssy/next, @cmssy/remix y @cmssy/astro, y en @cmssy/core para todo lo demás.

import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";

export async function POST(request: Request) {
  const body = await request.text();
  try {
    const event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: [process.env.CMSSY_WEBHOOK_SECRET, process.env.CMSSY_WEBHOOK_SECRET_PREVIOUS]
        .filter((s): s is string => !!s),
    });
    await handle(event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

La opción secret acepta un array, así que tu handler puede guardar el secreto nuevo y el anterior durante tu propia ventana de rotación y aceptar entregas firmadas con cualquiera de los dos.

La escalera de reintentos

Ocho intentos de entrega: el primero y después más 1 minuto, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas, unas 27 horas en total.
Ocho intentos en unas 27 horas.

cmssy espera 15 segundos una respuesta. Cualquier cosa que no sea un 2xx - y cualquier timeout - se reintenta: ocho intentos en total, con esperas de 1 minuto, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas. Un handler caído durante una tarde no pierde nada.

  • Las redirecciones son fallos. Un 3xx no se sigue. Registra la URL final.
  • Solo 410 Gone detiene la escalera. Cualquier otro 4xx se reintenta, porque «este endpoint ha terminado para siempre» y «el deploy dueño de esta ruta responde 404 por un momento» se ven igual desde fuera. Devuelve 410 cuando lo digas en serio.
  • Retry-After se respeta cuando pide más espera de la que haría la escalera, con un máximo de 10 horas. Nunca acorta el backoff.
  • Veinte fallos consecutivos desactivan el endpoint. Arregla el handler y vuelve a activarlo; eso reinicia el contador.

Las entregas se encolan por endpoint, con un solo intento en curso a la vez, así que un handler lento encola sus propios reintentos en lugar de recibir una avalancha por fan-out.

Escribe un handler que se pueda ejecutar dos veces

La entrega at-least-once significa que tu handler, tarde o temprano, verá la misma entrega dos veces - un timeout en tu lado que cmssy contó como fallo, un deploy que respondió 502 cuando el trabajo ya estaba hecho. Y como la entrega no está ordenada, un reintento puede llegar después de un evento más reciente sobre el mismo pedido.

  • Confirma rápido, trabaja después. Verifica, guarda la entrega, devuelve 2xx. Un handler que llama a tres APIs antes de responder acabará tardando más de 15 segundos y se reintentará mientras sigue trabajando.
  • Deduplica por el id de entrega. Un índice único sobre id en tu propia tabla convierte un duplicado en un no-op.
  • Lee el estado actual antes de los efectos secundarios. El payload es una instantánea del momento del evento. Cuando importa - emitir una factura, enviar un email, mover stock en otro sistema - vuelve a leer el pedido y actúa según lo que es cierto ahora.
  • Nunca dependas del orden de llegada. Compara el estado del payload, o de la lectura nueva, con lo que ya aplicaste.

Cuando algo sale mal

Cada intento queda registrado: list_webhook_deliveries (o Settings → Webhooks en el admin) muestra el estado, el código de respuesta y, para los miembros del equipo con webhooks:manage, los primeros 8 KiB del cuerpo de respuesta de tu handler - normalmente el stack trace que buscabas. Una vez arreglado el handler, reenvía las entregas fallidas. Un reenvío es una entrega nueva con su propio id; no cuenta para la escalera de la original ni para la racha de fallos del endpoint.

¿Una suscripción o varias?

Para invalidar caché, suscríbete una vez a content.changed e invalida ante cualquier cambio; cubre páginas, registros, modelos, formularios, medios y ajustes. Para automatizar, suscríbete a los eventos granulares que necesites - order.*, shipment.*, page.published, form.submitted y el resto. list_webhook_event_types devuelve la lista completa de eventos permitidos, filtrada según lo que tu rol puede suscribir.

La referencia completa está en la documentación de webhooks. Para un ejemplo práctico de handler idempotente, consulta facturar una tienda headless con webhooks.