Verify webhook signatures
Every delivery carries a Gryphem-Signature header:
Gryphem-Signature: t=1760961600,v1=186aa47526353adc39a808f8e0bd84f501a5a6a1f483572b1f8a3eab3ed44cc8The algorithm
Section titled “The algorithm”- 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.
- Parse the header:
tis a Unix timestamp; eachv1is a hex signature. Ignore unknown schemes. - Build the signed message:
<t>+"."+<raw body>. - Compute
HMAC-SHA256(key = the full secret string "whsec_…" as UTF-8, message)and hex-encode it. - Compare with each
v1value using a constant-time comparison. Any match is valid (several appear during rotation). - Reject if
|now − t|is greater than your tolerance (default 300 seconds) — this blocks replays. - 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.
Drop-in verifiers
Section titled “Drop-in verifiers”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).
import "github.com/gryphem-pay/gryphem-pay-go/webhook"
func handle(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) return } secrets := []string{os.Getenv("GRYPHEM_WEBHOOK_SECRET")} event, err := webhook.Verify(body, r.Header.Get(webhook.SignatureHeader), secrets, nil) if err != nil { http.Error(w, string(webhook.ReasonOf(err)), http.StatusBadRequest) return } // dedupe on event.EventID, then handle it w.WriteHeader(http.StatusOK)}import osfrom gryphem_pay import webhooksfrom gryphem_pay.webhooks import WebhookVerificationError
SECRETS = [os.environ["GRYPHEM_WEBHOOK_SECRET"]]
def handle(raw_body: bytes, signature_header: str) -> tuple[int, dict]: try: event = webhooks.verify(raw_body, signature_header, SECRETS) except WebhookVerificationError as exc: return 400, {"error": exc.reason} # dedupe on event["event_id"], then handle it return 200, {"received": True}signed = timestamp + "." + raw_bodyexpected = hex( HMAC_SHA256( key = "whsec_…", data = signed ) )valid = any( constant_time_equal(expected, v1) for v1 in header.v1 ) and abs(now - timestamp) <= 300Test vectors
Section titled “Test vectors”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.
Send yourself a signed test
Section titled “Send yourself a signed test”- Dashboard: Integrate → Webhooks → Send test event, or
POST /v1/webhook-endpoints/{id}/test, which queues a signedwebhook.testdelivery.
For unit tests, generate valid headers locally with generateTestHeader (TypeScript), generate_test_header
(Python) or webhook.GenerateTestHeader (Go).