---
title: "Webhook Events Overview"
canonical: "https://test.abhinandan.one/webhook-events"
markdown_url: "https://test.abhinandan.one/webhook-events.md"
publisher: "Primitive SDKs"
kind: "concept"
content_type: "reference"
category: "Core Concepts"
description: "The X-Webhook-Event header names every webhook delivery's event type, and one HMAC signature scheme verifies email, payment, and interaction bodies alike."
keywords: ["X-Webhook-Event header", "Primitive-Signature", "handleWebhookEvent", "WEBHOOK_EVENT_TYPES", "UnknownEvent", "interaction.x402.settled"]
last_modified: "2026-08-11T18:55:06.650778+00:00"
published_at: "2026-08-11T18:18:13.025192+00:00"
source_files:
  - "docs/architecture.md"
  - "openapi/primitive-api.yaml"
sections:
  - {anchor: "the-event-name-lives-in-a-header-not-the-body", title: "The event name lives in a header, not the body"}
  - {anchor: "signature-verification-is-one-scheme-for-every-family", title: "Signature verification is one scheme for every family"}
  - {anchor: "the-full-event-catalog", title: "The full event catalog"}
  - {anchor: "parsing-a-delivery-verify-then-classify", title: "Parsing a delivery: verify, then classify"}
  - {anchor: "forward-compatibility-is-a-hard-guarantee", title: "Forward compatibility is a hard guarantee"}
  - {anchor: "legacy-hard-typed-entry-point", title: "Legacy hard-typed entry point"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Webhook Events Overview

Every Primitive webhook delivery shares one signature scheme and one event-type discriminator across email, payment, and interaction families, and every SDK exposes the same typed union with built-in forward compatibility.

A Primitive webhook delivery is an HTTP POST to your endpoint, signed with one HMAC scheme and labeled with one header, regardless of whether it carries an inbound email, a payment settlement, or an x402-over-email interaction step. Every SDK (Node, Python, Go) parses that delivery into the same typed event union, so the contract in this page is identical no matter which language you're integrating.

## The event name lives in a header, not the body

Every webhook family sends its body **verbatim, with no shared envelope**. That means the field carrying the event name is different per family:

| Family | Body shape | Event name field |
|---|---|---|
| `email.*` | Full `EmailReceivedEvent`-style object | top-level `event` field |
| `payment.*` | Flat object | `type` field |
| `interaction.*` | `{ "interaction": { ... } }` | no field at all |

Because the body alone can't reliably discriminate every family, Primitive sends the canonical event name on the **`X-Webhook-Event` header** on every delivery, for every family. This header is the primary discriminator every SDK's parser keys on. A top-level `event` string in an `email.*` body is only a backward-compat fallback for senders that don't set the header.

```http
X-Webhook-Event: payment.settled
```

If a delivery has neither the header nor a body `event` field, the SDKs raise a payload error (`PAYLOAD_MISSING_EVENT`) rather than guessing.

## Signature verification is one scheme for every family

Every delivery, `email.*`, `payment.*`, `interaction.*` alike, is signed the same way, over the **raw request body**, independent of event type. The default scheme:

```http
Primitive-Signature: t=<unix-seconds>,v1=<hex>
```

- **Signed string**: `${timestamp}.${rawBody}`, where `rawBody` is the exact HTTP body bytes before any JSON parsing.
- **Signature**: HMAC-SHA256 of the signed string, hex-encoded, keyed with your account's webhook secret (from `GET /account/webhook-secret`) used as a UTF-8 string, do not base64-decode it despite its base64-shaped appearance.
- **Tolerance**: reject any delivery whose `t=` is more than 5 minutes off your wall clock; each SDK's verifier enforces this by default.
- **Legacy headers**: each delivery is signed once, and the same `t=...,v1=...` value is sent on `X-Primitive-Signature` and the legacy `X-Webhook-Signature` header alongside the primary `Primitive-Signature`. A legacy `MyMX-Signature` header carries the same value too, for integrations written before the rename.

Because verification runs on the raw body independent of event type, the exact same verifier code path handles `email.received`, `payment.settled`, and `interaction.x402.payment` deliveries, there's no per-family signature variant.

> **Note:** Need Standard Webhooks (`webhook-id`/`webhook-timestamp`/`webhook-signature`, `whsec_`-prefixed secret) instead of the Primitive-Signature HMAC? That's an alternative scheme offered alongside this default, see [Standard Webhooks Signature Support](https://test.abhinandan.one/node-sdk-webhook-signing/node-sdk-standard-webhooks.md) (Node), [Python](https://test.abhinandan.one/python-webhook-verification/python-standard-webhooks.md), or [Go](https://test.abhinandan.one/go-receiving-webhooks/go-standard-webhooks.md).

## The full event catalog

Every current webhook event type, grouped by family:

**Email** (subject = an email):
`email.received`, `email.bounced`, `email.tls_report`, `email.dmarc_report`, `email.dmarc_failure`

**Payment** (subject = a payment; see [x402 Payments Overview](https://test.abhinandan.one/x402-payments-overview.md)):
`payment.settled`, `payment.failed`

**Interaction** (subject = an interaction step in the x402-over-email or ack protocols):
`interaction.x402.challenge`, `interaction.x402.payment`, `interaction.x402.settled`, `interaction.x402.rejected`, `interaction.x402.declined`, `interaction.x402.expired`, `interaction.x402.verify_timeout`, `interaction.ack.received`, `interaction.ack.requested`, `interaction.ack.acked`, `interaction.ack.canceled`, `interaction.ack.expired`

Each SDK exports this catalog as a language-native constant:

| SDK | Export |
|---|---|
| Node | `WEBHOOK_EVENT_TYPES` (array), `WebhookEventType` (union type) |
| Python | `WEBHOOK_EVENT_TYPES` (tuple) |
| Go | `WebhookEventTypes` (slice) |

The normalized `ReceivedEmail` object and the full `EmailReceivedEvent` schema, the two representations of an `email.received` delivery, are explained once in [Inbound and Outbound Email Model](https://test.abhinandan.one/email-model.md); this page only covers the webhook transport contract shared by every event family.

## Parsing a delivery: verify, then classify

Every SDK follows the same two-step flow for any event family:

```mermaid
sequenceDiagram
    participant P as Primitive
    participant H as Your handler
    P->>H: POST body + Primitive-Signature + X-Webhook-Event
    H->>H: 1. Verify HMAC over raw body
    H->>H: 2. Classify on X-Webhook-Event header
    H->>H: Known type -> typed event
    H->>H: Unknown type -> UnknownEvent
```

The high-level entry point that does both steps in one call is `handleWebhookEvent` (Node/Go: `HandleWebhookEvent`, Python: `handle_webhook_event`). It:

1. Verifies the signature over the raw body (works identically for every family).
2. Parses the JSON body.
3. Classifies on the `X-Webhook-Event` header, returning a typed event for known types and an `UnknownEvent` for anything else, it does not throw on an unrecognized type.

**Choose one of the following:**

**Node.js**

```typescript
import {
  handleWebhookEvent,
  isPaymentSettledEvent,
  isInteractionX402Event,
} from "@primitivedotdev/sdk/webhook";

const event = handleWebhookEvent({
  body: rawBodyString,
  headers: req.headers,
  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});

if (isPaymentSettledEvent(event)) {
  // typed PaymentSettledEvent: flat fields, amount in token base units
  console.log("settled", event.challenge_id, event.amount, event.settle_tx);
} else if (isInteractionX402Event(event)) {
  console.log(event.event, event.interaction);
}
```

**Python**

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

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

if is_payment_settled_event(event):
    print(event["challenge_id"], event["amount"], event["settle_tx"])
elif is_interaction_x402_event(event):
    ...
```

**Go**

```go
event, err := primitive.HandleWebhookEvent(primitive.HandleWebhookOptions{
	Body:    rawBody,
	Headers: req.Header,
	Secret:  os.Getenv("PRIMITIVE_WEBHOOK_SECRET"),
})
if err != nil {
	// signature/verification failure
}

switch {
case primitive.IsPaymentSettledEvent(event):
	settled := event.(primitive.PaymentEvent)
	log.Println(settled.ChallengeID, settled.Amount, settled.SettleTx)
case primitive.IsInteractionX402Event(event):
	x402 := event.(primitive.InteractionEvent)
	_ = x402
}
```

`payment.*` events carry flat fields with amounts in token base units (USDC has 6 decimals). `interaction.*` events carry the full `{ interaction: {...} }` payload with the canonical `event` name overlaid by the parser (since the raw body has no event/type field of its own).

## Forward compatibility is a hard guarantee

New event types ship before SDK releases catch up to them, so every SDK treats an unrecognized `X-Webhook-Event` value as data, not an error:

- **Known types** (matching the current catalog) validate strictly, `email.received` bodies are checked against the full JSON Schema; malformed known-type bodies raise a validation error.
- **Unknown types** are returned as-is, wrapped in an `UnknownEvent` carrying the event name and the raw payload, instead of being rejected.

This behavior is enforced by the shared compatibility test suite (`test-fixtures/`) across all three SDKs, see [Monorepo Structure and Release Process](https://test.abhinandan.one/monorepo-and-releases.md) for how that shared fixture contract is maintained. Practically, it means your handler code doesn't break the day Primitive adds `payment.refunded` or a new `interaction.ack.*` suffix; you keep receiving deliveries and can add a case for the new type on your own schedule.

## Legacy hard-typed entry point

Every SDK keeps a legacy `handleWebhook` entry point (Go: `HandleWebhook`, Python: `handle_webhook`) that is hard-typed to `email.received` only, kept for callers written before payment and interaction events existed. It performs the same verify-then-parse flow but raises if the delivery isn't an `email.received` event. New integrations should use `handleWebhookEvent` unless they are intentionally scoped to email-only webhooks.
