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

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(...), 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, 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}".

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.

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

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

    Call handle_webhook with the body, headers, and secret#

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

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: 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 codeCauseFix
MISSING_SECRETsecret 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_HEADERHeader missing, malformed, or not in t=...,v1=... formConfirm you're reading the exact Primitive-Signature header value with no trimming or re-encoding
TIMESTAMP_OUT_OF_RANGEDelivery timestamp older than tolerance_seconds (default 300s) or more than 60s in the futureCheck server clock sync; raise tolerance_seconds only if you have a specific reason to accept older deliveries
SIGNATURE_MISMATCHComputed HMAC doesn't match any provided signatureConfirm 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.

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer