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}
| Header | Meaning |
|---|---|
webhook-id | The ID of the event. The same on every retry. Use it to drop duplicates. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | The signature in the Standard Webhooks format. |
X-KB-Signature | The same delivery signed in the earlier Sursa format. |
X-KB-Event | The event type. |
X-KB-Attempt | The attempt number, from 1. |
X-KB-Sequence | The 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.
| Format | Signed string | Key | Signature |
|---|---|---|---|
Standard Webhooks (webhook-signature) | <webhook-id>.<webhook-timestamp>.<body> | The part of the secret after whsec_, base64-decoded | v1, + base64 of HMAC-SHA256 |
Earlier format (X-KB-Signature) | <t>.<body> | The whole secret string, as text | v1= + 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' ' -f1Test 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.
| Input | Value |
|---|---|
| Secret | whsec_test_secret_0123456789abcdef |
webhook-id | evt_0123456789abcdef0123456789abcdef |
webhook-timestamp and t | 1791194400 |
| Body | {"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}} |
webhook-signature | v1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg= |
X-KB-Signature | t=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
429or503answer withRetry-Aftermoves 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 Goneanswer 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 alsoidin 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_atorsequencewith 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.
- Rotate, and store the new secret next to the old one in your receiver.
- Accept a delivery if it verifies with either secret.
- 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 answer2xxwithin 10 seconds. - Skip duplicates and stale events; read the current object through the API when it matters.
- Answer
429or503withRetry-Afterwhen you need Sursa to back off; never answer410by accident. - Skip event types and fields you do not know.
- Reconcile with the event feed on a schedule.