{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/python-webhook-verification","markdown_url":"https://test.abhinandan.one/python-webhook-verification.md","article":{"id":"96e82e06-9dfa-430f-985c-5c9c4249f1f8","article_slug":"python-webhook-verification","parent_article_slug":null,"parent_article_title":null,"kind":"guide","published_at":"2026-08-11T18:55:02.423859+00:00","keywords":["verify_webhook_signature","handle_webhook","Primitive-Signature header","WebhookVerificationError","verify_standard_webhooks_signature","whsec_"],"meta_description":"Verify an inbound Primitive webhook's Primitive-Signature HMAC header with verify_webhook_signature or handle_webhook before trusting the payload.","og_image_url":null,"source_file_paths":["sdk-python/src/primitive/webhook.py","sdk-python/README.md","sdk-python/tests/test_webhook.py"],"recording_id":null,"replayable":false,"task_name":"Verifying Webhook Signatures","category":"Python SDK","summary":null,"description":"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.","content_kind":"repo_page","content_markdown":"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.\n\nYou need this whenever you receive webhooks directly (i.e. you're not using [`primitive.receive(...)`](python-receive-email), 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.\n\n<Tip>\n\nIf you're calling `primitive.receive(...)` or `client.reply(...)` from [Receiving and Parsing Inbound Email](python-receive-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.\n\n</Tip>\n\n## The wire format\n\nPrimitive signs every delivery with a `Primitive-Signature` header carrying a Unix-seconds timestamp and a hex HMAC-SHA256 over `\"{timestamp}.{raw_body}\"`.\n\n```text\nPrimitive-Signature: t=<unix-seconds>,v1=<hex>\n```\n\n- **`t`**: the Unix-seconds timestamp the signature was generated at.\n- **`v1`**: the hex-encoded HMAC-SHA256 signature.\n- **Signed string**: `f\"{t}.{raw_body}\"`, where `raw_body` is the exact request bytes, before any JSON decoding.\n- **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.\n- **Legacy header**: `MyMX-Signature` carries the same value for backward compatibility. Prefer `Primitive-Signature`.\n- **Default tolerance**: reject deliveries whose timestamp is more than 300 seconds (5 minutes) old, or more than 60 seconds in the future.\n\n<Warning>\n\nVerify 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.\n\n</Warning>\n\n## Verify a signature directly\n\nUse `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.\n\n```python\nfrom primitive import verify_webhook_signature, WebhookVerificationError\n\ntry:\n    verify_webhook_signature(\n        raw_body=raw_body,          # bytes or str, exact request body\n        signature_header=request.headers[\"Primitive-Signature\"],\n        secret=\"whsec_...\",\n        tolerance_seconds=300,       # optional, defaults to 300\n    )\n    # Signature is valid; safe to parse and trust the body.\nexcept WebhookVerificationError as error:\n    print(error.code, error.message)\n    # e.g. SIGNATURE_MISMATCH, TIMESTAMP_OUT_OF_RANGE, INVALID_SIGNATURE_HEADER\n```\n\n`verify_webhook_signature` returns `True` on success and raises `WebhookVerificationError` on any failure. It never returns `False`; a failed check is always an exception.\n\n## Verify and parse in one call\n\n`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.\n\n<Steps>\n\n<Step title=\"Extract the raw body and headers from the request\">\n\nDo this before any JSON parsing. Most frameworks give you the raw bytes on the request object; grab them unmodified.\n\n```python\nraw_body: bytes = request.get_data()   # Flask example\nheaders: dict[str, str] = dict(request.headers)\n```\n\n</Step>\n\n<Step title=\"Call handle_webhook with the body, headers, and secret\">\n\n```python\nfrom primitive import handle_webhook, PrimitiveWebhookError\n\ntry:\n    event = handle_webhook(\n        body=raw_body,\n        headers=headers,\n        secret=\"whsec_...\",\n    )\n    print(event.event)  # \"email.received\"\nexcept PrimitiveWebhookError as error:\n    print(f\"[{error.code}] {error.message}\")\n```\n\n</Step>\n\n<Step title=\"Confirm the result\">\n\nOn 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:\n\n- `WebhookVerificationError`, bad or missing signature, expired timestamp\n- `WebhookPayloadError`, body isn't valid JSON, or is the wrong shape\n- `WebhookValidationError`, parsed JSON doesn't match the `email.received` schema\n\n</Step>\n\n</Steps>\n\n<Tip>\n\nNeed `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](python-webhook-events) for the event catalog and typed guards.\n\n</Tip>\n\n## Signing your own test payloads\n\n`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.\n\n```python\nimport json\n\nfrom primitive import sign_webhook_payload\n\nraw_body = json.dumps({\"event\": \"email.received\"})\nresult = sign_webhook_payload(raw_body, \"whsec_...\")\nprint(result[\"header\"])  # \"t=1700000000,v1=<hex>\"\n```\n\nPass an explicit `timestamp` (Unix seconds) as the third positional argument to pin the signed timestamp, for example when writing a deterministic test.\n\n## Standard Webhooks as an alternative\n\nPrimitive also supports [Standard Webhooks signature support](node-sdk-standard-webhooks): 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.\n\n## Common failure modes\n\n| Error code | Cause | Fix |\n| --- | --- | --- |\n| `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 |\n| `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 |\n| `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 |\n| `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 |\n\n<Warning>\n\nA `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.\n\n</Warning>\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Handling Webhook Events\" href=\"python-webhook-events\">\n\nParse and dispatch every webhook event family, email, payment, and interaction, with handle_webhook_event and the typed event catalog.\n\n</Card>\n\n<Card title=\"Standard Webhooks Signature Support (Python)\" href=\"python-standard-webhooks\">\n\nVerify or sign deliveries using the webhook-id/webhook-timestamp/webhook-signature convention instead of the default HMAC scheme.\n\n</Card>\n\n<Card title=\"Receiving and Parsing Inbound Email\" href=\"python-receive-email\">\n\nTurn a raw inbound webhook into a normalized ReceivedEmail object in one call, with verification handled for you.\n\n</Card>\n\n<Card title=\"Webhook Events Overview\" href=\"webhook-events\">\n\nUnderstand the shared webhook contract across every SDK: signature scheme, event catalog, and forward-compatibility guarantees.\n\n</Card>\n\n</CardGroup>","canonical_base_url":"https://test.abhinandan.one","seo_indexing_enabled":true,"last_modified":"2026-08-21T18:22:43.359885+00:00","video_url":null,"voiceover_url":null,"tools_used":[],"demonstrated_by":[],"steps":[],"related_links":[],"intro":null,"prerequisites":[],"verification":[],"troubleshooting":[],"suggest_edit_url":"https://github.com/abhi-browzer/primitive-sdks/edit/main/sdk-python/src/primitive/webhook.py","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Verifying+Webhook+Signatures&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fpython-webhook-verification","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}