---
title: "Verifying Webhook Signatures"
canonical: "https://test.abhinandan.one/python-webhook-verification"
markdown_url: "https://test.abhinandan.one/python-webhook-verification.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Python SDK"
description: "Verify an inbound Primitive webhook's Primitive-Signature HMAC header with verify_webhook_signature or handle_webhook before trusting the payload."
keywords: ["verify_webhook_signature", "handle_webhook", "Primitive-Signature header", "WebhookVerificationError", "verify_standard_webhooks_signature", "whsec_"]
last_modified: "2026-08-11T18:55:02.580071+00:00"
published_at: "2026-08-11T18:55:02.423859+00:00"
source_files:
  - "sdk-python/src/primitive/webhook.py"
  - "sdk-python/README.md"
  - "sdk-python/tests/test_webhook.py"
sections:
  - {anchor: "the-wire-format", title: "The wire format"}
  - {anchor: "verify-a-signature-directly", title: "Verify a signature directly"}
  - {anchor: "verify-and-parse-in-one-call", title: "Verify and parse in one call"}
  - {anchor: "step-extract-the-raw-body-and-headers-from-the-request", title: "Extract the raw body and headers from the request"}
  - {anchor: "step-call-handle_webhook-with-the-body-headers-and-secret", title: "Call handle_webhook with the body, headers, and secret"}
  - {anchor: "step-confirm-the-result", title: "Confirm the result"}
  - {anchor: "signing-your-own-test-payloads", title: "Signing your own test payloads"}
  - {anchor: "standard-webhooks-as-an-alternative", title: "Standard Webhooks as an alternative"}
  - {anchor: "common-failure-modes", title: "Common failure modes"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Verifying Webhook Signatures

Verify that an inbound Primitive webhook delivery is authentic and untampered before you trust its payload, using either the default HMAC scheme or Standard Webhooks.

Every webhook delivery from Primitive carries an HMAC-SHA256 signature over the raw request body. Verify it before you parse or act on the payload, or an attacker who guesses your endpoint URL can forge inbound email events, payment settlements, or interaction events.

You need this whenever you receive webhooks directly (i.e. you're not using [`primitive.receive(...)`](https://test.abhinandan.one/python-receive-email.md), which verifies for you automatically). Use it standalone when you only need the boolean verification result, or reach for `handle_webhook` / `handle_webhook_event` when you also want the parsed, typed payload in one call.

> **Tip:** If you're calling `primitive.receive(...)` or `client.reply(...)` from [Receiving and Parsing Inbound Email](https://test.abhinandan.one/python-receive-email.md), verification already happened. This page is for lower-level integrations: custom frameworks, proxies, or anything that hands you a raw body and headers instead of a normalized email.

## The wire format

Primitive signs every delivery with a `Primitive-Signature` header carrying a Unix-seconds timestamp and a hex HMAC-SHA256 over `"{timestamp}.{raw_body}"`.

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

- **`t`**: the Unix-seconds timestamp the signature was generated at.
- **`v1`**: the hex-encoded HMAC-SHA256 signature.
- **Signed string**: `f"{t}.{raw_body}"`, where `raw_body` is the exact request bytes, before any JSON decoding.
- **Secret**: your account's webhook secret. Use it as a UTF-8 string for the HMAC key; do not base64-decode it, even though it looks base64-shaped.
- **Legacy header**: `MyMX-Signature` carries the same value for backward compatibility. Prefer `Primitive-Signature`.
- **Default tolerance**: reject deliveries whose timestamp is more than 300 seconds (5 minutes) old, or more than 60 seconds in the future.

> **Warning:** Verify against the raw, unparsed request body. Re-serializing JSON before verifying (`json.dumps(json.loads(body))`) can silently change whitespace and break the signature check even for a legitimate delivery.

## Verify a signature directly

Use `verify_webhook_signature` when you only need a pass/fail check, for example inside a custom framework that already extracted the body and header for you.

```python
from primitive import verify_webhook_signature, WebhookVerificationError

try:
    verify_webhook_signature(
        raw_body=raw_body,          # bytes or str, exact request body
        signature_header=request.headers["Primitive-Signature"],
        secret="whsec_...",
        tolerance_seconds=300,       # optional, defaults to 300
    )
    # Signature is valid; safe to parse and trust the body.
except WebhookVerificationError as error:
    print(error.code, error.message)
    # e.g. SIGNATURE_MISMATCH, TIMESTAMP_OUT_OF_RANGE, INVALID_SIGNATURE_HEADER
```

`verify_webhook_signature` returns `True` on success and raises `WebhookVerificationError` on any failure. It never returns `False`; a failed check is always an exception.

## Verify and parse in one call

`handle_webhook(body=..., headers=..., secret=...)` verifies the signature, parses the JSON body, and validates it against the `email.received` schema, returning a typed `EmailReceivedEvent`. Use it when your integration only needs `email.received` events.

### 1. Extract the raw body and headers from the request

Do this before any JSON parsing. Most frameworks give you the raw bytes on the request object; grab them unmodified.

```python
raw_body: bytes = request.get_data()   # Flask example
headers: dict[str, str] = dict(request.headers)
```

### 2. Call handle_webhook with the body, headers, and secret

```python
from primitive import handle_webhook, PrimitiveWebhookError

try:
    event = handle_webhook(
        body=raw_body,
        headers=headers,
        secret="whsec_...",
    )
    print(event.event)  # "email.received"
except PrimitiveWebhookError as error:
    print(f"[{error.code}] {error.message}")
```

### 3. Confirm the result

On success, `event` is a validated `EmailReceivedEvent` dataclass with `event.email.headers`, `event.email.auth`, and the rest of the schema fields populated. On failure, `handle_webhook` raises one of:

- `WebhookVerificationError`, bad or missing signature, expired timestamp
- `WebhookPayloadError`, body isn't valid JSON, or is the wrong shape
- `WebhookValidationError`, parsed JSON doesn't match the `email.received` schema

> **Tip:** Need `payment.*` or `interaction.x402.*` events too, not just `email.received`? Use `handle_webhook_event` instead of `handle_webhook`. It runs the same verify-then-parse flow but returns the full typed event union. See [Handling Webhook Events](https://test.abhinandan.one/python-webhook-events.md) for the event catalog and typed guards.

## Signing your own test payloads

`sign_webhook_payload(raw_body, secret)` returns a dict with a `header` value in `t=...,v1=...` form, plus the `timestamp` and `v1` parts, so you can replay fixtures against your own handler.

```python
import json

from primitive import sign_webhook_payload

raw_body = json.dumps({"event": "email.received"})
result = sign_webhook_payload(raw_body, "whsec_...")
print(result["header"])  # "t=1700000000,v1=<hex>"
```

Pass an explicit `timestamp` (Unix seconds) as the third positional argument to pin the signed timestamp, for example when writing a deterministic test.

## Standard Webhooks as an alternative

Primitive also supports [Standard Webhooks signature support](https://test.abhinandan.one/node-sdk-webhook-signing/node-sdk-standard-webhooks.md): the `webhook-id` / `webhook-timestamp` / `webhook-signature` header convention with a `whsec_`-prefixed secret. `handle_webhook` and `handle_webhook_event` both detect Standard Webhooks headers automatically and verify accordingly, so you don't need to branch on scheme yourself. Reach for the Standard Webhooks helpers directly only if you're integrating with tooling that already expects that convention.

## Common failure modes

| Error code | Cause | Fix |
| --- | --- | --- |
| `MISSING_SECRET` | `secret` was empty, `None`, or `b""` | Pass your account's webhook secret as a UTF-8 string, exactly as issued; do not base64-decode it |
| `INVALID_SIGNATURE_HEADER` | Header missing, malformed, or not in `t=...,v1=...` form | Confirm you're reading the exact `Primitive-Signature` header value with no trimming or re-encoding |
| `TIMESTAMP_OUT_OF_RANGE` | Delivery timestamp older than `tolerance_seconds` (default 300s) or more than 60s in the future | Check server clock sync; raise `tolerance_seconds` only if you have a specific reason to accept older deliveries |
| `SIGNATURE_MISMATCH` | Computed HMAC doesn't match any provided signature | Confirm you're verifying the exact raw body bytes (no re-serialization) and the correct secret |

> **Warning:** A `SIGNATURE_MISMATCH` after re-serializing JSON (`json.dumps(json.loads(raw_body))`) is one of the most common integration bugs. The signed string is `f"{t}.{raw_body}"` over the exact bytes Primitive sent, and even insignificant whitespace changes break the HMAC. Always verify against the untouched body.
