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.
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}, whererawBodyis 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 onX-Primitive-Signatureand the legacyX-Webhook-Signatureheader alongside the primaryPrimitive-Signature. A legacyMyMX-Signatureheader 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.
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:
| 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; 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:
- Verifies the signature over the raw body (works identically for every family).
- Parses the JSON body.
- Classifies on the
X-Webhook-Eventheader, returning a typed event for known types and anUnknownEventfor 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);
}
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):
...
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.receivedbodies are checked against the full JSON Schema; malformed known-type bodies raise a validation error. - Unknown types are returned as-is, wrapped in an
UnknownEventcarrying 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#
Learn the normalized ReceivedEmail object and wait-mode delivery statuses that email.received events feed into.
x402 Payments OverviewSee how payment.* and interaction.x402.* events fit into the non-custodial payment flow.
Handling Payment and Interaction Webhook Events (Node)Full handleWebhookEvent reference and typed event guards for the Node.js SDK.
Standard Webhooks Signature SupportUse the webhook-id/webhook-timestamp/webhook-signature scheme instead of Primitive-Signature.
Was this page helpful?