Ir al contenido
Sursa Developers

Recibir webhooks

Un webhook avisa a su sistema de un cambio en Sursa en el momento en que ocurre: una tarjeta se publica, se verifica o se archiva, una pregunta se ha quedado sin respuesta. Sursa envía un POST HTTPS con el evento a una dirección que usted registra. Esta guía crea un receptor que funciona correctamente ante reintentos, cambios de orden y duplicados.

Para saber qué significa y qué contiene cada evento, consulte la descripción general de los webhooks y el catálogo de eventos de la referencia de la API.

1. Registrar un webhook

Un administrador registra los webhooks en Sursa, en Administration → Integrations → Webhooks and API (administración, integraciones, webhooks), o con POST /v1/webhooks y una clave con el rol admin y el ámbito webhooks:manage:

curl -X POST https://api.sursa.com/v1/webhooks \
  -H "Authorization: Bearer $SURSA_ADMIN_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/sursa",
    "description": "Sync verified cards to the help center",
    "events": ["card.published", "card.updated", "card.verified", "card.archived"]
  }'

La respuesta contiene el secreto de firma en secret, que empieza por whsec_. Se muestra una sola vez. Guárdelo junto a su receptor, por ejemplo en una variable de entorno SURSA_WEBHOOK_SECRET.

La dirección debe usar https y resolverse en direcciones IP públicas: las direcciones de redes privadas, de bucle invertido y de otras redes reservadas se rechazan al registrar el webhook y en cada entrega. Para desarrollar en su propio equipo, exponga su receptor local mediante un servicio de túnel que le dé una dirección HTTPS pública.

Envíe un evento de prueba en cualquier momento con POST /v1/webhooks/{webhook_id}/test. La respuesta muestra lo que respondió su receptor.

2. Cómo es una entrega

POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042

{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
EncabezadoSignificado
webhook-idEl ID del evento. Es el mismo en cada reintento. Úselo para descartar duplicados.
webhook-timestampCuándo se firmó este intento, en segundos Unix.
webhook-signatureLa firma en el formato Standard Webhooks.
X-KB-SignatureLa misma entrega firmada en el formato anterior de Sursa.
X-KB-EventEl tipo de evento.
X-KB-AttemptEl número de intento, desde 1.
X-KB-SequenceLa posición de esta entrega en el orden del webhook.

Los nombres de los encabezados no distinguen mayúsculas de minúsculas; su framework puede mostrarlos como X-Kb-Signature o x-kb-signature.

El cuerpo es el evento. Su data depende del tipo. Un webhook configurado con la carga útil thin recibe el evento sin data; en ese caso, lea el objeto mediante la API con su propia clave.

3. Comprobar la firma

Compruebe la firma de cada entrega antes de confiar en ella. Los dos formatos firman los bytes exactos del cuerpo, así que lea el cuerpo sin procesar antes de analizar el JSON: volver a serializar el JSON cambia los bytes y hace fallar la comprobación.

FormatoCadena firmadaClaveFirma
Standard Webhooks (webhook-signature)<webhook-id>.<webhook-timestamp>.<body>La parte del secreto que sigue a whsec_, decodificada de base64v1, + base64 del HMAC-SHA256
Formato anterior (X-KB-Signature)<t>.<body>La cadena completa del secreto, como textov1= + hex del HMAC-SHA256

Basta con comprobar una de las dos. Prefiera el encabezado Standard Webhooks: la misma comprobación está disponible en muchas bibliotecas ya hechas. Durante la rotación de un secreto, cada encabezado lleva una firma por cada secreto válido (separadas por espacios en webhook-signature, con v1= repetido en X-KB-Signature). Una entrega es válida si coincide cualquiera de ellas.

Rechace también una marca de tiempo que se aleje más de 5 minutos de su reloj. Así se impide que alguien vuelva a reproducir más tarde una entrega grabada.

# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"

# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A

# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

Pruebe su verificación de firmas

Ejecute su código con estos valores antes de conectarlo a las entregas reales. Con el reloj ajustado a la marca de tiempo, las dos comprobaciones deben superarse, y deben fallar cuando cambia un byte del cuerpo.

EntradaValor
Secretowhsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestamp y t1791194400
Cuerpo{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}}
webhook-signaturev1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg=
X-KB-Signaturet=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc

4. Responder rápido, trabajar después

Sursa espera su respuesta hasta 10 segundos. Cualquier estado 2xx significa que la entrega se completó correctamente; cualquier otra cosa, incluidos una redirección, un tiempo de espera agotado o una conexión cerrada, significa que falló y se reintentará.

Por eso, el receptor solo debe comprobar la firma, registrar el evento y responder. Haga el trabajo real después, desde su propia cola.

// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";

const SECRET = process.env.SURSA_WEBHOOK_SECRET;

http
  .createServer(async (req, res) => {
    const parts = [];
    for await (const part of req) parts.push(part);
    const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing

    const valid = req.headers["webhook-signature"]
      ? verifyStandard(SECRET, req.headers, body)
      : verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
    if (!valid) {
      res.writeHead(400).end();
      return;
    }

    const event = JSON.parse(body);
    await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
    res.writeHead(204).end();
  })
  .listen(3000);

Responda 2xx solo después de guardar el evento de forma segura. Si responde primero y después su proceso falla, el evento se pierde para usted, porque Sursa ya lo ha contado como entregado.

5. Reintentos

Una entrega fallida se reintenta con pausas cada vez más largas: después de aproximadamente 1 minuto, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas, 24 horas y 24 horas, nueve intentos en casi tres días. Cada pausa varía hasta un 20 % para que los reintentos de muchos webhooks no lleguen en el mismo momento.

  • Una respuesta 429 o 503 con Retry-After aplaza el siguiente intento al menos hasta ese momento, con un máximo de 24 horas. Úsela cuando su sistema esté sobrecargado o en mantenimiento.
  • Una respuesta 410 Gone desactiva el webhook: envíela solo cuando no quiera recibir más entregas en esa dirección.
  • Después de 10 intentos fallidos seguidos, el webhook se pone en pausa y se avisa a los administradores de la organización. Los eventos se siguen acumulando y, cuando se reanuda el webhook, en la interfaz de administración o con POST /v1/webhooks/{webhook_id}/resume, se entregan en orden.
  • Después del último intento, una entrega se marca como exhausted. Puede volver a enviarla a mano.

Las entregas de un webhook, con cada intento, el código que respondió y el tiempo que tardó, aparecen en GET /v1/webhooks/{webhook_id}/deliveries y en la interfaz de administración. POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver vuelve a enviar una entrega. Un reenvío tiene el mismo webhook-id.

6. Orden y duplicados

  • Orden. Las entregas a un mismo webhook se hacen estrictamente una tras otra, en el orden de sequence. Mientras se reintenta una entrega, las posteriores esperan. Los distintos webhooks no se esperan entre sí.
  • Duplicados. El mismo evento puede llegar más de una vez, por ejemplo cuando su respuesta se perdió por el camino. Guarde el webhook-id (que también es el id del cuerpo) de cada evento que procese y omita los que ya haya visto.
  • Pensar en estados. En los cambios de un mismo objeto, compare created_at o sequence con lo que guardó e ignore un evento más antiguo que el estado que ya tiene. En caso de duda, lea el objeto actual mediante la API: el evento le dice que algo ha cambiado; la API le dice cómo es ahora.

7. Conciliar con el feed de eventos

Cada evento también se conserva durante 30 días en el feed de eventos, tanto si un webhook estaba suscrito a él como si no. Si su receptor estuvo caído más tiempo del que cubren los reintentos, o si quiere comprobar que no se ha perdido nada, lea el feed con una clave que tenga el rol admin y el ámbito webhooks:manage:

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.*" \
  --data-urlencode "created_since=2026-10-20T00:00:00Z" \
  --data-urlencode "limit=200"

Un evento del feed tiene el mismo id y la misma forma que en una entrega, sin los campos de la entrega sequence, payload y test. Como su receptor omite los eventos que ya ha visto, puede ejecutar la conciliación tan a menudo como quiera, por ejemplo una vez por hora.

GET /v1/webhook-events/{event_id} lee un evento por su ID.

8. Rotar el secreto

POST /v1/webhooks/{webhook_id}/rotate-secret emite un secreto nuevo y mantiene válido el anterior durante un período de solapamiento, de 24 horas de forma predeterminada y hasta 7 días. Durante el solapamiento, cada entrega se firma con los dos secretos.

  1. Rote el secreto y guarde el nuevo junto al anterior en su receptor.
  2. Acepte una entrega si se verifica con cualquiera de los dos secretos.
  3. Cuando termine el solapamiento, elimine el secreto anterior.

Si el secreto anterior se ha filtrado, termine el solapamiento de inmediato con POST /v1/webhooks/{webhook_id}/revoke-previous-secret o rote el secreto con "overlap_hours": 0.

Lista de comprobación

  • Compruebe una de las firmas sobre el cuerpo sin procesar y compruebe la marca de tiempo.
  • Guarde el evento, identificado por webhook-id, y después responda 2xx en menos de 10 segundos.
  • Omita los duplicados y los eventos obsoletos; cuando importe, lea el objeto actual mediante la API.
  • Responda 429 o 503 con Retry-After cuando necesite que Sursa reduzca el ritmo; nunca responda 410 por error.
  • Omita los tipos de evento y los campos que no conozca.
  • Concilie periódicamente con el feed de eventos.