---
title: "Signature Verification"
description: "Verify Primitive webhook signatures with SDK helpers or the manual HMAC-SHA256 flow."
canonical: "https://docs.primitive.dev/docs/signature-verification"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Signature Verification

Primitive signs outbound webhook deliveries so your endpoint can verify that the payload came from Primitive and was not modified in transit.

The header is:


```
Primitive-Signature: t=<unix-seconds>,v1=<hex-hmac>
```


Verify the exact raw request body. Do not parse JSON and re-stringify it before verification.

## Which Secret

A webhook is signed with either your organization's webhook secret or a secret of its own. The endpoint's `signed_with` says which: `organization_secret` or `own_secret`.

- `organization_secret`: read it with `GET /v1/account/webhook-secret` (`primitive account webhook-secret`). A webhook the organization owns signs with it until it is given its own, as does one that someone who reads every mailbox creates for an API key with `owner_key_id`.

- `own_secret`: read it with `GET /v1/endpoints/{id}/signing-secret`. A webhook a person owns signs with its own secret, as do one an API key limited to some mailboxes, a third-party app or an agent delegate made for itself, and one a member limited to their own mail made for a key they hold. A webhook that signs with its own secret keeps signing with one when its owner changes, though it may be a new secret, and `POST /v1/endpoints/{id}/signing-secret/rotate` gives any webhook its own. Each of these webhooks has a different secret, so a handler serving several of them needs each one's.

Read `signed_with` again after a webhook changes owner. Rotating the organization's secret leaves every webhook's own secret as it is. Either rotation signs a webhook's deliveries with the new secret from the moment the new secret takes effect, just before the rotation answers, so be ready to update the handler when you rotate and switch it as soon as you have the new secret. A delivery the handler rejects during the switch may not be retried: find it in the [delivery log](/docs/endpoints#webhook-deliveries) and [replay it](/docs/endpoints#replay-a-delivery). If a rotation's response is lost, read the secret again (`GET /v1/account/webhook-secret` or `GET /v1/endpoints/{id}/signing-secret`) before rotating again: the organization's rotation may still be running, and [Webhook signing secrets](/docs/auth#webhook-signing-secrets) says how to tell.

## SDK Helpers


```
import {
  verifyWebhookSignature,
  WebhookVerificationError,
  PRIMITIVE_SIGNATURE_HEADER,
} from '@primitivedotdev/sdk/api';


export async function POST(req: Request) {
  const rawBody = await req.text();
  const signatureHeader = req.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? '';


  try {
    await verifyWebhookSignature({
      rawBody,
      signatureHeader,
      secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response('invalid signature', { status: 401 });
    }
    throw error;
  }


  return Response.json({ ok: true });
}
```

```
import os
from primitive import WebhookVerificationError, verify_webhook_signature


async def handle(request):
    raw_body = await request.body()
    signature_header = request.headers.get('Primitive-Signature', '')


    try:
        verify_webhook_signature(
            raw_body=raw_body,
            signature_header=signature_header,
            secret=os.environ['PRIMITIVE_WEBHOOK_SECRET'],
        )
    except WebhookVerificationError:
        return {'status': 401, 'body': 'invalid signature'}


    return {'ok': True}
```

```
body, _ := io.ReadAll(r.Body)
signatureHeader := r.Header.Get("Primitive-Signature")


_, err := primitive.VerifyWebhookSignature(primitive.VerifyOptions{
    RawBody:         body,
    SignatureHeader: signatureHeader,
    Secret:          os.Getenv("PRIMITIVE_WEBHOOK_SECRET"),
})
if err != nil {
    http.Error(w, "invalid signature", http.StatusUnauthorized)
    return
}
```


## Manual Flow

- Parse the `t` and `v1` parts of `Primitive-Signature`.

- Reject timestamps outside your tolerance window.

- Build the signed payload as `<timestamp>.<raw-body>`.

- Compute HMAC-SHA256 with your webhook signing secret.

- Compare the expected hex digest to `v1` using a constant-time comparison.

## Common Mistakes

- Reading `request.json()` before verification.

- Verifying a re-serialized JSON string instead of raw bytes.

- Comparing strings with normal equality instead of a timing-safe comparison.

- Returning 4xx for event types you intentionally ignore. Verify first, then return 2xx for ignored but trusted events.

## Related Pages

- [Receiving Mail](/docs/receiving): webhook delivery behavior.

- [Webhook Payload](/docs/webhook-payload): event schema and fields.

- [SDKs](/docs/sdks): high-level receive helpers.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
