---
title: "Webhook Signature Verification"
canonical: "https://test.abhinandan.one/node-sdk-webhook-signing"
markdown_url: "https://test.abhinandan.one/node-sdk-webhook-signing.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Node.js SDK"
description: "Verify the Primitive-Signature HMAC-SHA256 header manually with verifyWebhookSignature when your framework doesn't expose a standard Request object."
keywords: ["verifyWebhookSignature", "Primitive-Signature header", "WebhookVerificationError", "toleranceSeconds", "@primitivedotdev/sdk/webhook", "MyMX-Signature"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:54:51.370053+00:00"
source_files:
  - "sdk-node/README.md"
sections:
  - {anchor: "the-wire-format", title: "The wire format"}
  - {anchor: "verify-a-delivery-manually", title: "Verify a delivery manually"}
  - {anchor: "step-capture-the-raw-body-and-signature-header", title: "Capture the raw body and signature header"}
  - {anchor: "step-call-verifywebhooksignature", title: "Call verifyWebhookSignature"}
  - {anchor: "step-handle-the-result", title: "Handle the result"}
  - {anchor: "override-the-replay-tolerance", title: "Override the replay tolerance"}
  - {anchor: "debugging-a-signature-mismatch", title: "Debugging a signature mismatch"}
  - {anchor: "next-steps", title: "Next steps"}
---

> Documentation index: https://test.abhinandan.one/llms.txt

# 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(...)`](https://test.abhinandan.one/node-sdk-receiving-email.md) 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](https://test.abhinandan.one/email-model.md). 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}`.

```http
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. 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.

```ts
// 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/webhook` subpath and pass the raw body, the header value, and your webhook secret.

```ts
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. Handle the result

`verifyWebhookSignature` returns nothing on success and throws `WebhookVerificationError` on mismatch, expired timestamp, or malformed input.

```ts
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.

```ts
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.</br>

## 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](https://api.primitive.dev/v1/openapi).
