Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /webhook-events.md.
Verified · 8/11/2026

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:

FamilyBody shapeEvent name field
email.*Full EmailReceivedEvent-style objecttop-level event field
payment.*Flat objecttype 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.

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:

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 (Node), Python, or Go.

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

SDKExport
NodeWEBHOOK_EVENT_TYPES (array), WebhookEventType (union type)
PythonWEBHOOK_EVENT_TYPES (tuple)
GoWebhookEventTypes (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; 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:

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.
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);
}

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

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer