Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /node-sdk-webhook-signing.md.
Verified · 8/11/2026

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.

Tip

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}, where rawBody is 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. verifyWebhookSignature enforces 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.

Note

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. 1

    Capture the raw body and signature header#

    Read the request body as a string or Buffer, not as parsed JSON, and pull the Primitive-Signature header 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. 2

    Call verifyWebhookSignature#

    Import the helper from the @primitivedotdev/sdk/webhook subpath 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!,
    });
    

    rawBody must be the exact bytes of the HTTP body (string or Buffer) before any JSON parsing. signatureHeader is the Primitive-Signature header value verbatim.

  3. 3

    Handle the result#

    verifyWebhookSignature returns nothing on success and throws WebhookVerificationError on 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,
});
Warning

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:

  1. Re-serialized body. Confirm you're verifying the exact raw bytes, not a value that passed through JSON.parse/JSON.stringify anywhere in your stack (a logging middleware is a common culprit).
  2. Base64-decoded secret. The secret returned by GET /account/webhook-secret looks base64-shaped but is not base64. Use it as-is, as a UTF-8 string.
  3. Wrong header. Confirm you're reading primitive-signature (case-insensitive) and not stripping or altering it in a proxy layer.
  4. 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#

Was this page helpful?

© Primitive SDKs

Powered by Browzer