{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/python-webhook-events","markdown_url":"https://test.abhinandan.one/python-webhook-events.md","article":{"id":"d7a45f3e-25d6-4a9d-802b-53218f65f0ef","article_slug":"python-webhook-events","parent_article_slug":null,"parent_article_title":null,"kind":"guide","published_at":"2026-08-11T18:55:02.962888+00:00","keywords":["handle_webhook_event","is_payment_settled_event","is_interaction_x402_event","WEBHOOK_EVENT_TYPES","X-Webhook-Event header","UnknownEvent"],"meta_description":"handle_webhook_event verifies the HMAC signature then returns a typed union of email.*, payment.*, and interaction.x402.* webhook events in one call.","og_image_url":null,"source_file_paths":["sdk-python/src/primitive/events.py","sdk-python/src/primitive/webhook.py","sdk-python/README.md","sdk-python/tests/test_webhook.py"],"recording_id":null,"replayable":false,"task_name":"Handling Webhook Events","category":"Python SDK","summary":null,"description":"Use handle_webhook_event to verify, parse, and branch on every Primitive webhook family, email.*, payment.*, and interaction.x402.*, from a single endpoint, with forward-compatible handling for event types the SDK doesn't know about yet.","content_kind":"repo_page","content_markdown":"`handle_webhook_event` verifies a delivery and returns the full typed event union, so one endpoint can handle inbound mail, x402 payment settlements, and email-native payment interactions. All three families arrive on the **same webhook URL**, distinguished only by the `X-Webhook-Event` header.\n\nIf your endpoint only ever needs `email.received`, use the narrower [`handle_webhook`](python-webhook-verification) instead, it's hard-typed to that one event and skips the union-narrowing step.\n\n## Why the header, not the body, decides the event type\n\nThe header is the only discriminator present on all three families: `interaction.*` bodies carry no event or type field at all. Primitive posts every webhook with the event name in the **`X-Webhook-Event`** header, and the stored payload is sent verbatim with no wrapping envelope, and each event family shapes that payload differently:\n\n| Family | Body shape | Event name lives in |\n|---|---|---|\n| `email.*` | Full parsed/raw email object | Body field `event` |\n| `payment.*` | Flat settlement fields | Body field `type` |\n| `interaction.*` | `{\"interaction\": {...}}` | Nowhere in the body |\n\nBecause `interaction.*` bodies carry no discriminator at all, the header is the only reliable signal across every family. `handle_webhook_event` reads it for you and overlays a canonical `event` key onto the parsed result so your code always branches on one field.\n\n<Tip>\n\nSignature verification runs on the **raw body** and is independent of the event type, it works identically whether the delivery is `email.*`, `payment.*`, or `interaction.*`. See [Verifying Webhook Signatures](python-webhook-verification) for the HMAC and Standard Webhooks details this function relies on internally.\n\n</Tip>\n\n## Verify and dispatch in one call\n\nPass the raw body, the request headers, and your webhook secret to `handle_webhook_event`, then narrow the returned event with the typed guards.\n\n<Steps>\n\n<Step title=\"Import handle_webhook_event and the typed guards\">\n\n```python\nfrom primitive import (\n    handle_webhook_event,\n    is_payment_settled_event,\n    is_payment_failed_event,\n    is_interaction_x402_event,\n)\n```\n\n</Step>\n\n<Step title=\"Call it with the raw body, headers, and your webhook secret\">\n\n`body` must be the exact request bytes before any JSON parsing; `headers` is any mapping-like object (e.g. `request.headers` from your framework). Get the secret from your Primitive dashboard or `GET /account/webhook-secret`.\n\n```python\nimport os\n\nevent = handle_webhook_event(\n    body=raw_body,\n    headers=request.headers,\n    secret=os.environ[\"PRIMITIVE_WEBHOOK_SECRET\"],\n)\n```\n\nThis verifies the `Primitive-Signature` HMAC (or a Standard Webhooks signature, if those headers are present) over the raw body first, then parses the JSON and classifies it using the `X-Webhook-Event` header. Verification failures raise `WebhookVerificationError`; a malformed body raises `WebhookPayloadError`.\n\n</Step>\n\n<Step title=\"Branch on the typed guards\">\n\n```python\nif is_payment_settled_event(event):\n    # flat fields; amount is in token base units (USDC has 6 decimals)\n    print(event[\"challenge_id\"], event[\"amount\"], event[\"settle_tx\"])\nelif is_payment_failed_event(event):\n    print(event[\"challenge_id\"], event[\"failure_reason\"])\nelif is_interaction_x402_event(event):\n    # interaction.x402.* lifecycle: challenge, payment, settled, rejected, ...\n    print(event[\"event\"], event[\"interaction\"])\nelif event.get(\"event\") == \"email.received\":\n    # a normal inbound email; hand off to your ReceivedEmail flow\n    ...\nelse:\n    # UnknownEvent: a future event type the SDK doesn't have a typed\n    # shape for yet. Log it and move on rather than raising.\n    print(\"unhandled event:\", event.get(\"event\"))\n```\n\n**Expected result:** each branch fires exactly once per delivery, keyed off the header the platform sent, regardless of whether the body carries `event`, `type`, or nothing at all.\n\n</Step>\n\n</Steps>\n\n## The full event catalog\n\n`WEBHOOK_EVENT_TYPES`, a tuple in `primitive.events`, enumerates all 19 event types the `X-Webhook-Event` header can currently carry: five `email.*`, two `payment.*`, and twelve `interaction.*`.\n\n```python\nfrom primitive.events import WEBHOOK_EVENT_TYPES, is_known_webhook_event_type\n\nprint(WEBHOOK_EVENT_TYPES)\n# ('email.received', 'email.bounced', 'email.tls_report', 'email.dmarc_report',\n#  'email.dmarc_failure', 'payment.settled', 'payment.failed',\n#  'interaction.ack.acked', 'interaction.ack.canceled', 'interaction.ack.expired',\n#  'interaction.ack.received', 'interaction.ack.requested',\n#  'interaction.x402.challenge', 'interaction.x402.declined',\n#  'interaction.x402.expired', 'interaction.x402.payment',\n#  'interaction.x402.rejected', 'interaction.x402.settled',\n#  'interaction.x402.verify_timeout')\n\nis_known_webhook_event_type(\"payment.settled\")  # True\nis_known_webhook_event_type(\"email.future_event\")  # False\n```\n\nThree sub-catalogs feed into it, exported separately if you only care about one family:\n\n- `EMAIL_EVENT_TYPES`, the five first-party email events (subject: an email)\n- `PAYMENT_EVENT_TYPES`, `payment.settled`, `payment.failed` (subject: a payment)\n- `INTERACTION_EVENT_TYPES`, the `interaction.ack.*` and `interaction.x402.*` step events (subject: an interaction)\n\n## Typed shapes for payment and interaction events\n\n`primitive.events` exports `PaymentEvent`, `PaymentSettledEvent`, `PaymentFailedEvent`, and `InteractionEvent` as `TypedDict`s, so type checkers and your editor know which fields are available after a guard narrows the type.\n\n```python\nfrom primitive.events import (\n    InteractionEvent,\n    PaymentEvent,\n    PaymentFailedEvent,\n    PaymentSettledEvent,\n)\n```\n\n- **`PaymentEvent`**: base shape shared by both settlement outcomes, `event`, `type`, `challenge_id`, `network`, `amount` (base units), `asset`, `payer_org`\n- **`PaymentSettledEvent`**: `PaymentEvent` narrowed to `event: Literal[\"payment.settled\"]`, adds `settle_tx`\n- **`PaymentFailedEvent`**: `PaymentEvent` narrowed to `event: Literal[\"payment.failed\"]`, adds `failure_reason`\n- **`InteractionEvent`**: `event`, `interaction` (a dict), optional `id`, covers every `interaction.ack.*` and `interaction.x402.*` step\n\nUse the four guard functions rather than checking `event[\"event\"] == \"...\"` directly; they handle both dict and dataclass-shaped inputs and keep your dispatch code forward-compatible if the internal representation ever changes.\n\n## Forward compatibility: UnknownEvent\n\nAn event type outside the current catalog comes back as an `UnknownEvent`, the body plus an overlaid `event` field, instead of raising, so a new Primitive event type never 500s your handler. A payload whose header names a type outside the current catalog is **not** rejected. `parse_webhook_event` (called internally by `handle_webhook_event`) returns it as an `UnknownEvent`, the body plus an overlaid `event` field, instead of raising. This is deliberate: Primitive can ship new event types before your SDK version updates, and your handler should degrade gracefully rather than 500.\n\n```python\nimport logging\nimport os\n\nfrom primitive import handle_webhook_event\nfrom primitive.events import is_known_webhook_event_type\n\nlogger = logging.getLogger(__name__)\n\nevent = handle_webhook_event(\n    body=raw_body,\n    headers=request.headers,\n    secret=os.environ[\"PRIMITIVE_WEBHOOK_SECRET\"],\n)\n\nif not is_known_webhook_event_type(event.get(\"event\")):\n    # Log for visibility, but don't crash the request.\n    logger.info(\"unrecognized webhook event: %s\", event.get(\"event\"))\n```\n\n<Warning>\n\nIf neither the `X-Webhook-Event` header nor a body `event` field is present, `handle_webhook_event` raises `WebhookPayloadError` with code `PAYLOAD_MISSING_EVENT`. The real Primitive sender always sets the header, so this only fires on hand-constructed test payloads that forgot it, pass the header explicitly in tests, or call `parse_webhook_event(body, event_type=...)` directly if you're building fixtures.\n\n</Warning>\n\n## email.received still needs normalization\n\n`handle_webhook_event` hands back the raw `EmailReceivedEvent`, so call `normalize_received_email` on it when you need the normalized `ReceivedEmail` fields. It returns the raw, schema-validated `email.received` webhook payload (`EmailReceivedEvent`), not the normalized `ReceivedEmail` object. If your `email.received` branch needs `sender`, `reply_target`, or `thread`, pass the event to `primitive.received_email.normalize_received_email`, see [Receiving and Parsing Inbound Email](python-receive-email) for the full shape.\n\n## Legacy alternative: handle_webhook\n\n`handle_webhook` runs the same signature verification but is hard-typed to `email.received`: it validates every body against that schema, so a `payment.*` or `interaction.*` delivery raises `WebhookValidationError`. Keep using it only if your integration genuinely never needs payment or interaction events; otherwise `handle_webhook_event` is a strict superset with no extra cost.\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Payment and Interaction Webhook Event Types\" href=\"python-webhook-event-types\">\n\nBrowse the full WEBHOOK_EVENT_TYPES catalog and every TypedDict field in detail.\n\n</Card>\n\n<Card title=\"Verifying Webhook Signatures\" href=\"python-webhook-verification\">\n\nUnderstand the HMAC and Standard Webhooks schemes handle_webhook_event verifies against.\n\n</Card>\n\n<Card title=\"Creating and Paying Challenges\" href=\"python-x402-charge-and-pay\">\n\nSee where payment.settled and payment.failed events originate in the x402 payment flow.\n\n</Card>\n\n<Card title=\"Email-Native Payments\" href=\"python-x402-email-payments\">\n\nLearn the interaction.x402.* lifecycle these events report on for email-carried challenges.\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/events.py","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Handling+Webhook+Events&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fpython-webhook-events","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}