---
title: "Handling Webhook Events"
canonical: "https://test.abhinandan.one/python-webhook-events"
markdown_url: "https://test.abhinandan.one/python-webhook-events.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Python SDK"
description: "handle_webhook_event verifies the HMAC signature then returns a typed union of email.*, payment.*, and interaction.x402.* webhook events in one call."
keywords: ["handle_webhook_event", "is_payment_settled_event", "is_interaction_x402_event", "WEBHOOK_EVENT_TYPES", "X-Webhook-Event header", "UnknownEvent"]
last_modified: "2026-08-11T18:55:03.134811+00:00"
published_at: "2026-08-11T18:55:02.962888+00:00"
source_files:
  - "sdk-python/src/primitive/events.py"
  - "sdk-python/src/primitive/webhook.py"
  - "sdk-python/README.md"
  - "sdk-python/tests/test_webhook.py"
sections:
  - {anchor: "why-the-header-not-the-body-decides-the-event-type", title: "Why the header, not the body, decides the event type"}
  - {anchor: "verify-and-dispatch-in-one-call", title: "Verify and dispatch in one call"}
  - {anchor: "step-import-handle_webhook_event-and-the-typed-guards", title: "Import handle_webhook_event and the typed guards"}
  - {anchor: "step-call-it-with-the-raw-body-headers-and-your-webhook-secret", title: "Call it with the raw body, headers, and your webhook secret"}
  - {anchor: "step-branch-on-the-typed-guards", title: "Branch on the typed guards"}
  - {anchor: "the-full-event-catalog", title: "The full event catalog"}
  - {anchor: "typed-shapes-for-payment-and-interaction-events", title: "Typed shapes for payment and interaction events"}
  - {anchor: "forward-compatibility-unknownevent", title: "Forward compatibility: UnknownEvent"}
  - {anchor: "emailreceived-still-needs-normalization", title: "email.received still needs normalization"}
  - {anchor: "legacy-alternative-handle_webhook", title: "Legacy alternative: handle_webhook"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Handling Webhook Events

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.

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

If your endpoint only ever needs `email.received`, use the narrower [`handle_webhook`](https://test.abhinandan.one/python-webhook-verification.md) instead, it's hard-typed to that one event and skips the union-narrowing step.

## Why the header, not the body, decides the event type

The 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:

| Family | Body shape | Event name lives in |
|---|---|---|
| `email.*` | Full parsed/raw email object | Body field `event` |
| `payment.*` | Flat settlement fields | Body field `type` |
| `interaction.*` | `{"interaction": {...}}` | Nowhere in the body |

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

> **Tip:** Signature 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](https://test.abhinandan.one/python-webhook-verification.md) for the HMAC and Standard Webhooks details this function relies on internally.

## Verify and dispatch in one call

Pass the raw body, the request headers, and your webhook secret to `handle_webhook_event`, then narrow the returned event with the typed guards.

### 1. Import handle_webhook_event and the typed guards

```python
from primitive import (
    handle_webhook_event,
    is_payment_settled_event,
    is_payment_failed_event,
    is_interaction_x402_event,
)
```

### 2. Call it with the raw body, headers, and your webhook secret

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

```python
import os

event = handle_webhook_event(
    body=raw_body,
    headers=request.headers,
    secret=os.environ["PRIMITIVE_WEBHOOK_SECRET"],
)
```

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

### 3. Branch on the typed guards

```python
if is_payment_settled_event(event):
    # flat fields; amount is in token base units (USDC has 6 decimals)
    print(event["challenge_id"], event["amount"], event["settle_tx"])
elif is_payment_failed_event(event):
    print(event["challenge_id"], event["failure_reason"])
elif is_interaction_x402_event(event):
    # interaction.x402.* lifecycle: challenge, payment, settled, rejected, ...
    print(event["event"], event["interaction"])
elif event.get("event") == "email.received":
    # a normal inbound email; hand off to your ReceivedEmail flow
    ...
else:
    # UnknownEvent: a future event type the SDK doesn't have a typed
    # shape for yet. Log it and move on rather than raising.
    print("unhandled event:", event.get("event"))
```

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

## The full event catalog

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

```python
from primitive.events import WEBHOOK_EVENT_TYPES, is_known_webhook_event_type

print(WEBHOOK_EVENT_TYPES)
# ('email.received', 'email.bounced', 'email.tls_report', 'email.dmarc_report',
#  'email.dmarc_failure', 'payment.settled', 'payment.failed',
#  'interaction.ack.acked', 'interaction.ack.canceled', 'interaction.ack.expired',
#  'interaction.ack.received', 'interaction.ack.requested',
#  'interaction.x402.challenge', 'interaction.x402.declined',
#  'interaction.x402.expired', 'interaction.x402.payment',
#  'interaction.x402.rejected', 'interaction.x402.settled',
#  'interaction.x402.verify_timeout')

is_known_webhook_event_type("payment.settled")  # True
is_known_webhook_event_type("email.future_event")  # False
```

Three sub-catalogs feed into it, exported separately if you only care about one family:

- `EMAIL_EVENT_TYPES`, the five first-party email events (subject: an email)
- `PAYMENT_EVENT_TYPES`, `payment.settled`, `payment.failed` (subject: a payment)
- `INTERACTION_EVENT_TYPES`, the `interaction.ack.*` and `interaction.x402.*` step events (subject: an interaction)

## Typed shapes for payment and interaction events

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

```python
from primitive.events import (
    InteractionEvent,
    PaymentEvent,
    PaymentFailedEvent,
    PaymentSettledEvent,
)
```

- **`PaymentEvent`**: base shape shared by both settlement outcomes, `event`, `type`, `challenge_id`, `network`, `amount` (base units), `asset`, `payer_org`
- **`PaymentSettledEvent`**: `PaymentEvent` narrowed to `event: Literal["payment.settled"]`, adds `settle_tx`
- **`PaymentFailedEvent`**: `PaymentEvent` narrowed to `event: Literal["payment.failed"]`, adds `failure_reason`
- **`InteractionEvent`**: `event`, `interaction` (a dict), optional `id`, covers every `interaction.ack.*` and `interaction.x402.*` step

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

## Forward compatibility: UnknownEvent

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

```python
import logging
import os

from primitive import handle_webhook_event
from primitive.events import is_known_webhook_event_type

logger = logging.getLogger(__name__)

event = handle_webhook_event(
    body=raw_body,
    headers=request.headers,
    secret=os.environ["PRIMITIVE_WEBHOOK_SECRET"],
)

if not is_known_webhook_event_type(event.get("event")):
    # Log for visibility, but don't crash the request.
    logger.info("unrecognized webhook event: %s", event.get("event"))
```

> **Warning:** If 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.

## email.received still needs normalization

`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](https://test.abhinandan.one/python-receive-email.md) for the full shape.

## Legacy alternative: handle_webhook

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