Verify webhook signatures, retries and delivery history
Every outgoing webhook delivery is signed with your subscription's secret so you can confirm it came from Unstack. Failed deliveries are retried, and a subscription that keeps failing is turned off.
Headers on a delivery
X-Unstack-Signature:t=<unix seconds>,v1=<hex signature>X-Unstack-Timestamp: the same unix timestamp.X-Unstack-Event: the event name, such ascustom.customers.created.
Every delivery carries these headers: the first attempt, each retry and test deliveries.
How the signature is made
The signature is an HMAC-SHA256, in hex, of the string <timestamp>.<raw request body>, using your subscription secret as the key. Verify it against the raw body exactly as received, before any JSON parsing.
```javascript
import crypto from "crypto";
function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
const ts = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!ts || !v1) return false;
// Reject anything older than 5 minutes.
const age = Math.floor(Date.now() / 1000) - ts;
if (age > 300 || age < -60) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(${ts}.${rawBody})
.digest("hex");
if (v1.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
}
```
Checking the timestamp matters. It stops someone replaying an old delivery they captured.
If the secret is lost or exposed
On the Webhooks tab of Data & API, click Rotate secret on the webhook, or call POST /api/v1/webhooks/{id}/rotate-secret. A new secret is made and shown once. The old one stops working right away, and every delivery from then on, retries included, is signed with the new one. Changing a webhook's URL or events does not change its secret.
Order and timing
Every changed record sends its own event, with no limit on how many. A large change, such as an import or a bulk edit, is delivered over the following minutes rather than all at once.
Events for one app are delivered in the order the changes happened, one at a time. A delivery that failed is retried later, so a retry can arrive after newer events.
Delivery normally happens within seconds of the change, but it can lag by up to about a minute. The timestamp in the payload is the time the record changed, not the time the event was delivered.
Retries
A delivery fails if your endpoint does not answer with a 2xx status within 10 seconds. Failed deliveries are retried after 1 minute, then 5 minutes, then 30 minutes, then 2 hours. After the last attempt the delivery is marked as permanently failed.
Retried deliveries carry an X-Unstack-Delivery-Attempt header with the attempt number. A retry is signed the same way as the first attempt, with the timestamp of the retry, so the check above accepts it even hours after the first attempt.
Make your handler safe to call twice. Delivery is at-least-once: a retry can arrive after your endpoint already processed the first attempt but answered too slowly, and now and then an event is delivered twice. The record id together with the payload timestamp identifies one change, and a retry carries the same payload, so keep the pairs you have handled and skip one you have seen before.
When a subscription is turned off
Each failed attempt adds to the subscription's failure count, and a successful delivery resets it to zero. A subscription is set to inactive only when deliveries have failed at least 5 times in a row and have been failing for an hour. A short outage or a deploy on your side does not turn it off.
While your endpoint is failing, new events for your app wait in order instead of being sent into the failure. They are delivered, oldest first, once your endpoint answers again. If the subscription is turned off, the events still waiting for it are not sent.
To turn it back on, fix your endpoint, then open Tools, choose Data & API, open the Webhooks tab and click Resume on the webhook. It is marked Turned off there. With the API, send:
```bash
curl -X PATCH "https://unstack.us/api/v1/webhooks/SUBSCRIPTION_ID" \
-H "X-API-Key: usk_your_api_key" \
-H "Content-Type: application/json" \
-d '{"is_active": true}'
```
Pausing or resuming a webhook yourself also resets the failure count. So does changing its URL, which also turns a webhook that was turned off for failing back on.
Delivery history
On the Webhooks tab of Data & API, click Deliveries on a webhook to see its most recent deliveries, newest first: the event, whether it was delivered, the HTTP status your endpoint answered with, the attempt number and the time. Click Refresh to update the list.
With the API, GET /api/v1/webhooks/{id}/deliveries returns the delivery attempts for a subscription, newest first, with the event, the response status and whether it was delivered. It accepts limit (up to 100) and offset.
Test deliveries
Click Send test on a webhook, or call POST /api/v1/webhooks/{id}/test. Either one sends a payload with the event webhook.test to your URL and shows the status your endpoint answered with. Test deliveries appear in the delivery history, marked Test. A failed test is not retried and does not count toward turning the webhook off. Test deliveries are signed like real ones, so a test is also a check of your verification code. Do not act on the webhook.test event itself.
Checklist for your endpoint
- Read the raw body before parsing it.
- Verify the signature and the timestamp.
- Answer with a 2xx status quickly, then do slow work afterwards.
- Treat the same event arriving twice as normal.