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.
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}", whereraw_bodyis 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-Signaturecarries the same value for backward compatibility. PreferPrimitive-Signature. - Default tolerance: reject deliveries whose timestamp is more than 300 seconds (5 minutes) old, or more than 60 seconds in the future.
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
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
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
Confirm the result#
On success,
eventis a validatedEmailReceivedEventdataclass withevent.email.headers,event.email.auth, and the rest of the schema fields populated. On failure,handle_webhookraises one of:WebhookVerificationError, bad or missing signature, expired timestampWebhookPayloadError, body isn't valid JSON, or is the wrong shapeWebhookValidationError, parsed JSON doesn't match theemail.receivedschema
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 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 |
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#
Parse and dispatch every webhook event family, email, payment, and interaction, with handle_webhook_event and the typed event catalog.
Standard Webhooks Signature Support (Python)Verify or sign deliveries using the webhook-id/webhook-timestamp/webhook-signature convention instead of the default HMAC scheme.
Receiving and Parsing Inbound EmailTurn a raw inbound webhook into a normalized ReceivedEmail object in one call, with verification handled for you.
Webhook Events OverviewUnderstand the shared webhook contract across every SDK: signature scheme, event catalog, and forward-compatibility guarantees.
Was this page helpful?