Skip to content

Verify webhook signatures

Every delivery carries a Gryphem-Signature header:

Gryphem-Signature: t=1760961600,v1=186aa47526353adc39a808f8e0bd84f501a5a6a1f483572b1f8a3eab3ed44cc8
  1. Read the raw request body exactly as received (bytes). Do not parse and re-serialise JSON first — any change in whitespace or key order breaks the signature.
  2. Parse the header: t is a Unix timestamp; each v1 is a hex signature. Ignore unknown schemes.
  3. Build the signed message: <t> + "." + <raw body>.
  4. Compute HMAC-SHA256(key = the full secret string "whsec_…" as UTF-8, message) and hex-encode it.
  5. Compare with each v1 value using a constant-time comparison. Any match is valid (several appear during rotation).
  6. Reject if |now − t| is greater than your tolerance (default 300 seconds) — this blocks replays.
  7. Only now parse the JSON and process the event, deduplicating on event_id.

Failure reasons used by the official libraries: malformed_header, no_signatures, signature_mismatch, timestamp_outside_tolerance, invalid_payload. Respond 400 to any of them.

import { createServer } from 'node:http';
import { verifyWebhook, WebhookVerificationError } from '@gryphem-pay/webhooks';
const secrets = [process.env.GRYPHEM_WEBHOOK_SECRET!]; // add the previous secret while rotating
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks); // the RAW body
try {
const event = await verifyWebhook({ payload: raw, header: req.headers['gryphem-signature'] as string, secrets });
// dedupe on event.event_id, then handle it
res.writeHead(200).end();
} catch (err) {
const reason = err instanceof WebhookVerificationError ? err.reason : 'error';
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: reason }));
}
}).listen(4400);

In Next.js route handlers use await request.text() (not request.json()) to get the raw body. A synchronous Node variant is available from @gryphem-pay/webhooks/node (verifyWebhookSync).

The repository ships cross-language vectors in packages/test-fixtures/src/webhook-vectors.json — valid signatures, rotation with two signatures, tampered body, wrong secret, expired and future timestamps, missing and malformed headers. The TypeScript, Python and Go libraries all run every case in CI. If you write your own verifier, run it against the same file.

  • Dashboard: Integrate → Webhooks → Send test event, or
  • POST /v1/webhook-endpoints/{id}/test, which queues a signed webhook.test delivery.

For unit tests, generate valid headers locally with generateTestHeader (TypeScript), generate_test_header (Python) or webhook.GenerateTestHeader (Go).