Skip to content

Verify webhooks

Check that each webhook request really came from Soosh, answer it in time, and skip the ones you've already handled.

15 minutesDevelopersUpdated

Anyone who finds your webhook URL can send it a request. Soosh signs every request with your endpoint’s secret, following Standard Webhooks, so your server can turn away anything Soosh didn’t send.

Every endpoint has its own secret. It starts with whsec_.

Keep it with your other secrets, for example in an environment variable called SOOSH_WEBHOOK_SECRET. Never put it in your code.

2. Read the three headers and the raw body

Section titled “2. Read the three headers and the raw body”

Each request is a POST with a JSON body and these headers. The body is an event. The full request is in Webhook delivery.

Header What it holds
webhook-id The ID of this delivery. It’s the same on every try.
webhook-timestamp When Soosh sent this try, in Unix seconds.
webhook-signature v1, and a base64 signature. While a secret is being rotated there are two, separated by a space.

The signature covers the body exactly as Soosh sent it. Parsing the JSON and turning it back into text changes spaces and order, and the signature no longer matches. So read the body as raw bytes, check it, and only then parse it.

Copy the code for your language. It:

  • turns away requests whose timestamp is more than 5 minutes from your server’s clock, so a captured request can’t be sent again later;
  • signs webhook-id, ., webhook-timestamp, . and the body with HMAC-SHA256, using the base64-decoded part of the secret after whsec_ as the key;
  • compares the result with each v1 signature in the header, in constant time, and accepts the request if any one matches.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
// Returns true when a webhook request really came from Soosh.
// secret the endpoint's signing secret, starting whsec_
// headers the request headers, with lower-case names (as Node gives them)
// body the raw request body, exactly as received: a Buffer or string,
// before any JSON parsing
export function verifyWebhook(secret, headers, body, now = Math.floor(Date.now() / 1000)) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !signatures || !/^\d+$/.test(timestamp ?? '')) return false;
// Refuse requests more than 5 minutes old (or ahead), so a captured
// request can't be replayed later.
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
// The key is the base64-decoded part of the secret after whsec_.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
// There can be several signatures, separated by spaces: during a secret
// rotation, one per secret. Any one that matches is enough.
return signatures.split(' ').some((signature) => {
const [version, value] = signature.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

Use it with the raw body. In Express:

import express from 'express';
import { verifyWebhook } from './verify.mjs';
const app = express();
app.post('/soosh', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(process.env.SOOSH_WEBHOOK_SECRET, req.headers, req.body)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
console.log(event.type, event.data);
res.sendStatus(204);
});
app.listen(3000);

This code is tested against the Standard Webhooks test vector. If your server’s clock is wrong, every request looks too old or too new: keep it in sync with NTP.

Answer with any 2xx status within 10 seconds. Anything else counts as a failure, and so does a redirect: Soosh doesn’t follow them. A failed request is sent again later, for about 2 days. See Webhook delivery for when.

If handling an event takes longer, save it, answer, and do the work afterwards, for example from a queue.

Soosh can send the same event more than once: when your server handled it but answered too late, for example. A delivery keeps its webhook-id every time it’s sent: each retry, and a resend or replay from the app.

  1. When a request comes in, look up its webhook-id. If you’ve handled it, answer 204 and stop.
  2. Handle the event.
  3. Only once it’s handled, save its webhook-id and answer with a 2xx. If handling fails, don’t save it: answer with an error, and the event is sent again.
  4. Keep the IDs for at least 30 days. Retries of a delivery go on for about 2 days, but a failed delivery is kept for 30 days, and can be resent or replayed from the app until then.

Events can also arrive out of order. Order them by the times in data, such as at in message.status, not by when they arrive.

Go to Settings > Webhooks (opens Soosh in a new tab), open the webhook and click Test webhook. Soosh sends an event of type webhook.test, signed like every other request. Its data is {"test": true, "message": "This is a test event from Soosh"}. Your server should accept it.

If your server turns it away, check that you sign the raw body, that the secret is the whole value starting whsec_, and that your clock is right.

Your server accepts requests from Soosh and turns away everything else, so only real events reach your systems.

If a secret may have leaked, give the endpoint a new one. In the app, click Rotate secret on the webhook. Over the API, call POST /v1/webhooks/{id}/rotate-secret.

You choose how long the old secret keeps signing, from 0 to 168 hours (24 by default). Until then, every request carries both signatures, and the code above accepts either. Put the new secret on your server, then let the old one run out.