Webhook Signature Verification
Verify the Primitive-Signature HMAC header by hand with verifyWebhookSignature when your framework doesn't give you a standard Request object to pass to primitive.receive.
Use verifyWebhookSignature when you need to check a webhook delivery's authenticity yourself: a non-standard framework, a language bridge proxying through Node, or a one-off audit of a captured request. Most app code never needs this, because primitive.receive(...) already verifies the signature for you in one call.
If your framework hands you a standard Request object (Next.js route handlers, Cloudflare Workers, Deno), use primitive.receive(req, { secret }) instead. It extracts the raw body, verifies the signature, and returns a normalized ReceivedEmail. Reach for verifyWebhookSignature only when you have already pulled the raw body and header value yourself.
The wire format#
Every webhook delivery carries a Primitive-Signature header holding a unix-seconds timestamp and a hex HMAC-SHA256 signature over ${timestamp}.${rawBody}.
Primitive-Signature: t=<unix-seconds>,v1=<hex>
- Signed string:
${timestamp}.${rawBody}, whererawBodyis the exact request bytes before any JSON decoding. - Signature: HMAC-SHA256 of the signed string, hex-encoded.
- Secret: returned by
GET /account/webhook-secret. Use it as a UTF-8 string; do not base64-decode it, even though it looks base64-shaped. - Tolerance: reject deliveries whose
t=timestamp is more than 5 minutes (300 seconds) off your wall clock.verifyWebhookSignatureenforces this by default.
A legacy MyMX-Signature header carries the same value for back-compat with integrations written before the rename. New code should read Primitive-Signature.
The signature is computed over the raw request body. If your framework re-serializes JSON before you see it (JSON.parse then JSON.stringify), the signature check fails even though the payload "looks" identical, because whitespace and key order differ from what was signed.
Verify a delivery manually#
Capture the raw body and the Primitive-Signature header value, pass both plus your webhook secret to verifyWebhookSignature, and treat a thrown WebhookVerificationError as a rejected delivery.
- 1
Capture the raw body and signature header#
Read the request body as a string or
Buffer, not as parsed JSON, and pull thePrimitive-Signatureheader value verbatim.// Your framework's raw-body accessor; must return the exact bytes. const rawBody = await getRawRequestBody(req); // string | Buffer const signatureHeader = req.headers["primitive-signature"] as string; - 2
Call verifyWebhookSignature#
Import the helper from the
@primitivedotdev/sdk/webhooksubpath and pass the raw body, the header value, and your webhook secret.import { verifyWebhookSignature } from "@primitivedotdev/sdk/webhook"; verifyWebhookSignature({ rawBody, signatureHeader, secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, });rawBodymust be the exact bytes of the HTTP body (string orBuffer) before any JSON parsing.signatureHeaderis thePrimitive-Signatureheader value verbatim. - 3
Handle the result#
verifyWebhookSignaturereturns nothing on success and throwsWebhookVerificationErroron mismatch, expired timestamp, or malformed input.import { verifyWebhookSignature, WebhookVerificationError, } from "@primitivedotdev/sdk/webhook"; try { verifyWebhookSignature({ rawBody, signatureHeader, secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, }); } catch (err) { if (err instanceof WebhookVerificationError) { // signature mismatch, expired timestamp, or malformed header return new Response("invalid signature", { status: 400 }); } throw err; }Verification signal: no exception thrown means the delivery is authentic and within the replay window. Proceed to parse the JSON body.
Override the replay tolerance#
Pass toleranceSeconds to change the replay window, which defaults to 300 seconds (5 minutes). Widen it only when your infrastructure adds queueing delay before the handler runs.
import { verifyWebhookSignature } from "@primitivedotdev/sdk/webhook";
verifyWebhookSignature({
rawBody,
signatureHeader,
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
toleranceSeconds: 600,
});
Widening the tolerance window increases your exposure to replay attacks: a captured, valid request can be resent successfully for the full window. Only widen it if you accept that tradeoff.
Debugging a signature mismatch#
Four causes account for nearly every false mismatch: a re-serialized body, a base64-decoded secret, a stripped or altered header, and clock skew. Check them in that order.
If verifyWebhookSignature throws on a delivery you believe is genuine:
- Re-serialized body. Confirm you're verifying the exact raw bytes, not a value that passed through
JSON.parse/JSON.stringifyanywhere in your stack (a logging middleware is a common culprit). - Base64-decoded secret. The secret returned by
GET /account/webhook-secretlooks base64-shaped but is not base64. Use it as-is, as a UTF-8 string. - Wrong header. Confirm you're reading
primitive-signature(case-insensitive) and not stripping or altering it in a proxy layer. - Clock skew. If the timestamp is more than 5 minutes off your server's wall clock, verification fails even with a correct signature. Check NTP sync.
For the full wire-level reference (response codes, replay protection details), see the "Webhook signing" section of the OpenAPI spec.
Next steps#
Use primitive.receive to verify and normalize a webhook delivery in one call: the recommended path for most app code.
Handling Payment and Interaction Webhook EventsUse handleWebhookEvent to verify and branch on email., payment., and interaction.x402.* deliveries from a single endpoint.
Standard Webhooks Signature SupportVerify deliveries using the webhook-id/webhook-timestamp/webhook-signature convention instead of the Primitive-Signature HMAC header.
Node.js SDK ErrorsLook up WebhookVerificationError codes and what triggers each one.
Was this page helpful?