Skip to content
Sursa Developers

Receive webhooks

A webhook tells your system about a change in Sursa as it happens: a card is published, verified or archived, a question went unanswered. Sursa sends an HTTPS POST with the event to an address you register. This guide builds a receiver that is correct under retries, reordering and duplicates.

For what each event means and contains, see the webhooks overview and the event catalog in the API reference.

1. Register an endpoint

An administrator registers endpoints in Sursa under Administration → Integrations → Webhooks and API, or with POST /v1/webhooks and a key with the admin role and the webhooks:manage scope:

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"]
  }'

The response contains the signing secret in secret, starting with whsec_. It is shown once. Store it with your receiver, for example in an environment variable SURSA_WEBHOOK_SECRET.

The address must use https and resolve to public IP addresses: addresses in private, loopback and other reserved networks are rejected when you register and on every delivery. To develop on your own computer, expose your local receiver through a tunnelling service that gives it a public HTTPS address.

Send a test event at any time with POST /v1/webhooks/{webhook_id}/test. The response shows what your receiver answered.

2. What a delivery looks like

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}
HeaderMeaning
webhook-idThe ID of the event. The same on every retry. Use it to drop duplicates.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signatureThe signature in the Standard Webhooks format.
X-KB-SignatureThe same delivery signed in the earlier Sursa format.
X-KB-EventThe event type.
X-KB-AttemptThe attempt number, from 1.
X-KB-SequenceThe position of this delivery in the order of the endpoint.

Header names are case-insensitive; your framework may show them as X-Kb-Signature or x-kb-signature.

The body is the event. Its data depends on the type. An endpoint set to the thin payload receives the event without data; read the object through the API with your own key instead.

3. Verify the signature

Check the signature of every delivery before you trust it. Both formats sign the exact bytes of the body, so read the raw body before any JSON parsing: re-serializing the JSON changes the bytes and breaks the check.

FormatSigned stringKeySignature
Standard Webhooks (webhook-signature)<webhook-id>.<webhook-timestamp>.<body>The part of the secret after whsec_, base64-decodedv1, + base64 of HMAC-SHA256
Earlier format (X-KB-Signature)<t>.<body>The whole secret string, as textv1= + hex of HMAC-SHA256

Verifying either one is enough. Prefer the Standard Webhooks header: the same check is available in many ready-made libraries. During a secret rotation, each header carries one signature per valid secret (space-separated in webhook-signature, repeated v1= in X-KB-Signature). A delivery is valid if any of them matches.

Also reject a timestamp more than 5 minutes away from your clock. This stops someone from replaying a recorded delivery later.

# 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

Test your verifier

Run your code against these values before you connect it to real deliveries. With the clock set to the timestamp, both checks must pass, and must fail when one byte of the body changes.

InputValue
Secretwhsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestamp and t1791194400
Body{"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. Answer fast, work later

Sursa waits up to 10 seconds for your answer. Any 2xx status means the delivery succeeded; anything else, including a redirect, a timeout or a closed connection, means it failed and will be retried.

So the receiver should only verify, record the event, and answer. Do the real work afterwards, from your own queue.

// 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);

Answer 2xx only after the event is safely stored. If you answer first and then crash, the event is lost for you, because Sursa has already counted it as delivered.

5. Retries

A failed delivery is retried with growing pauses: after about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours and 24 hours, nine attempts over almost three days. Each pause varies by up to 20 % so that retries from many endpoints do not arrive at the same moment.

  • A 429 or 503 answer with Retry-After moves the next attempt to at least that time, up to 24 hours later. Use it when your system is overloaded or in maintenance.
  • A 410 Gone answer disables the endpoint: send it only when you want no more deliveries at that address.
  • After 10 failed attempts in a row, the endpoint is paused, and the organization's administrators are told. Events keep accumulating, and when the endpoint is resumed, in the admin interface or with POST /v1/webhooks/{webhook_id}/resume, they are delivered in order.
  • After the last attempt, a delivery is marked exhausted. You can send it again by hand.

The deliveries of an endpoint, with each attempt, your answer code and its timing, are listed by GET /v1/webhooks/{webhook_id}/deliveries and in the admin interface. POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver sends one again. A redelivery has the same webhook-id.

6. Order and duplicates

  • Order. Deliveries to one endpoint go strictly one after another in the order of sequence. While one delivery is being retried, the later ones wait. Different endpoints do not wait for each other.
  • Duplicates. The same event can arrive more than once, for example when your answer was lost on the way back. Store the webhook-id (it is also id in the body) of every event you process, and skip one you have already seen.
  • Thinking in states. For changes to the same object, compare created_at or sequence with what you stored, and ignore an event that is older than the state you already have. When in doubt, read the current object through the API: the event tells you that something changed, the API tells you what it is now.

7. Reconcile with the event feed

Every event is also kept for 30 days in the event feed, whether or not an endpoint was subscribed to it. If your receiver was down for longer than the retries cover, or you want to check that nothing was missed, read the feed with a key that has the admin role and the webhooks:manage scope:

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"

An event in the feed has the same id and shape as in a delivery, without the delivery fields sequence, payload and test. Because your receiver skips events it has already seen, you can run the reconciliation as often as you like, for example once an hour.

GET /v1/webhook-events/{event_id} reads one event by its ID.

8. Rotate the secret

POST /v1/webhooks/{webhook_id}/rotate-secret issues a new secret and keeps the previous one valid for an overlap period, 24 hours by default and up to 7 days. During the overlap, every delivery is signed with both secrets.

  1. Rotate, and store the new secret next to the old one in your receiver.
  2. Accept a delivery if it verifies with either secret.
  3. After the overlap, remove the old secret.

If the old secret has leaked, end the overlap at once with POST /v1/webhooks/{webhook_id}/revoke-previous-secret, or rotate with "overlap_hours": 0.

Checklist

  • Verify one of the signatures on the raw body, and check the timestamp.
  • Store the event, keyed by webhook-id, then answer 2xx within 10 seconds.
  • Skip duplicates and stale events; read the current object through the API when it matters.
  • Answer 429 or 503 with Retry-After when you need Sursa to back off; never answer 410 by accident.
  • Skip event types and fields you do not know.
  • Reconcile with the event feed on a schedule.