Verifying deliveries
The headers SolidHooks signs each delivery with, and how to verify them in your endpoint before trusting the payload.
Every delivery SolidHooks sends to your endpoint is signed with the destination's signing secret (whsec_...). Verify the signature before you act on the payload. It proves the request came from SolidHooks and that the body was not altered on the way.
This page is about deliveries going out. For verifying webhooks coming in from a provider, see Verification.
Two schemes
Each destination uses one of two signature schemes. signatureScheme on the destination tells you which.
| Scheme | Default for | Headers |
|---|---|---|
standard_webhooks | New destinations | webhook-id, webhook-timestamp, webhook-signature |
solidhooks_v1 | Destinations created before Standard Webhooks became the default | x-solidhooks-signature, x-solidhooks-timestamp |
solidhooks_v1 is supported indefinitely. Standard Webhooks is the right choice for almost everything, because any Standard Webhooks library verifies it in one call.
Standard Webhooks
webhook-id: dlv_... the delivery id
webhook-timestamp: 1755300000 unix seconds
webhook-signature: v1,<base64> space-delimited list, may hold more than one valueTo verify:
- Build the signed content as
<webhook-id>.<webhook-timestamp>.<raw body>. - Compute the HMAC-SHA256 of it, base64-encoded. The key is the secret with the
whsec_prefix removed and the rest base64-decoded. Use the decoded bytes, not the literal string. - Compare it, in constant time, against each entry in
webhook-signature. Accept the request if any entry matches.
webhook-signature is a list, so never treat the first entry as authoritative. During a rotation overlap it carries signatures for both the new and the previous secret, and you can adopt the new secret on your own schedule.
solidhooks_v1
x-solidhooks-signature: <hex>
x-solidhooks-timestamp: 1755300000 unix secondsTo verify, compute the hex HMAC-SHA256 of <x-solidhooks-timestamp>.<raw body> and compare it in constant time to x-solidhooks-signature. The key is the whole whsec_... string exactly as written. It is not base64-decoded. The header carries a single value, so this scheme cannot express a rotation overlap.
Verification code
Both samples verify the raw body bytes and reject timestamps more than five minutes from now.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
function timestampIsFresh(timestamp: string): boolean {
const seconds = Number(timestamp);
return Number.isInteger(seconds) && Math.abs(Date.now() / 1000 - seconds) <= TOLERANCE_SECONDS;
}
// Standard Webhooks. `rawBody` is the exact bytes received, as a Buffer.
export function verifyStandardWebhooks(
secret: string, // whsec_...
headers: Record<string, string | undefined>,
rawBody: Buffer
): boolean {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures || !timestampIsFresh(timestamp)) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const expected = `v1,${createHmac('sha256', key).update(signed).digest('base64')}`;
return signatures.split(' ').some((entry) => safeEqual(entry, expected));
}
// solidhooks_v1
export function verifySolidhooksV1(
secret: string, // whsec_..., used as-is
headers: Record<string, string | undefined>,
rawBody: Buffer
): boolean {
const signature = headers['x-solidhooks-signature'];
const timestamp = headers['x-solidhooks-timestamp'];
if (!signature || !timestamp || !timestampIsFresh(timestamp)) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = createHmac('sha256', secret).update(signed).digest('hex');
return safeEqual(signature, expected);
}Rules that apply to both schemes
- Use the raw body. Verify against the exact bytes you received, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
- Check the timestamp. Reject anything outside your tolerance window to block replays. Five minutes is typical.
- Deduplicate on
webhook-id. Under Standard Webhooks it is the delivery id, which stays the same across retries of one delivery, so use it as your idempotency key. A delivery can reach you more than once. - Treat a failed check as a failed delivery. Return a non-2xx status and SolidHooks will retry.
Get the signing secret
curl -s "$API/v1/destinations/dest_.../signing-secret" \
-H "authorization: Bearer $SDHK_KEY"The secret is whsec_ followed by standard base64 of 32 random bytes. Reading it needs an API key scoped destination: read & write. A key without that grant gets 403 INSUFFICIENT_API_KEY_PERMISSIONS.
Switch scheme
curl -s "$API/v1/destinations/dest_.../signature-scheme" \
-H "authorization: Bearer $SDHK_KEY" \
-H 'content-type: application/json' \
-d '{ "scheme": "standard_webhooks" }'Switching is breaking and has no grace period
It mints a new secret and switches immediately. An overlap signs with two secrets under one scheme and cannot span two schemes. Make your endpoint accept the new scheme first, then switch, then fetch the new secret.
Binary payloads and JavaScript receivers
SolidHooks signs the exact bytes it delivers. The reference standardwebhooks JavaScript library converts a Buffer payload to a string before computing its MAC, which is lossy for bytes that are not valid UTF-8, so it rejects those deliveries. This is deliberate: signing the lossy form would let one signature authenticate many different payloads.
If you deliver non-UTF-8 bodies, verify against the raw bytes. The Go, Python and PHP implementations already do. Only a JavaScript receiver is affected, and for that case choose solidhooks_v1, where you write the check yourself and nothing decodes the body before it is hashed. The Node.js sample above works on the raw Buffer under either scheme.
Rotating the secret
POST /v1/destinations/{id}/rotate-signing-secret mints a new secret. On a standard_webhooks destination you can pass overlapDays of 1, 7 or 30, and deliveries carry both secrets' signatures until the window closes. Without overlapDays, and always on solidhooks_v1, rotation is a hard cutover: deliveries are signed with the new secret only the moment the call returns. See Secret rotation.
Next steps
- Secret rotation — roll secrets without dropping deliveries
- Retries — what happens when verification fails and you return an error
- Destinations — create and configure the endpoint