Docs/Sending
Signing secrets
How webhooks are signed, which secret is used, and how to generate one.
Every request carries three headers from the Standard Webhooks spec:
| Header | Value |
|---|---|
webhook-id | The event id, like evt_3f1c…. Same on every retry |
webhook-timestamp | Unix time in seconds, fresh on every attempt |
webhook-signature | v1, followed by the base64 signature |
The signature is HMAC-SHA256 over {id}.{timestamp}.{body}, where body is the exact JSON string that is sent.
Secret formats
A secret that starts with whsec_ is base64-decoded, and the decoded bytes are the key. This is the format Svix uses. Any other non-empty string is used as-is, as UTF-8.
A whsec_ secret with nothing after the prefix, or with invalid base64, is rejected: send() returns invalid signing secret for subscriber <url> instead of signing with a key anyone could guess.
Generate a secret
Use 32 random bytes:
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"Share it with the receiver over a channel you trust. Both sides need the same value.
Which secret is used
- The subscriber's own
secret, if it has one. - Otherwise
signing.secretfrom the config.
With neither, send() returns no signing secret for subscriber <url>. Giving each subscriber its own secret means one leaked secret only exposes one receiver.
const wh = webhooks({
events,
subscribers: async ({ subject }) => {
const endpoint = await db.endpoint.findFirstOrThrow({ where: { accountId: subject } });
return [{ url: endpoint.url, secret: endpoint.secret }];
},
});In the outbox, the secret is stored on the row, so a worker signs with the secret that was current when the event was sent.
Signing in your own code
The SDK exports the two helpers it uses, for tests and tooling:
import { isValidSecret, signPayload } from "@typedhook/sdk";
isValidSecret("whsec_"); // false
const signature = await signPayload(secret, "evt_1", 1791655200, body); // "v1,…"