Handling Payment and Interaction Webhook Events
Verify and branch on every webhook family, email.*, payment.*, and interaction.x402.*, from a single endpoint using handleWebhookEvent and its typed event guards.
Use handleWebhookEvent, the verify-then-classify entry point in @primitivedotdev/sdk/webhook, to handle email.*, payment.*, and interaction.x402.* deliveries from a single endpoint. Reach for it when the same webhook URL you registered for inbound mail also receives x402 settlement and interaction events.
If your integration only ever needs inbound mail, use primitive.receive(...) or handleWebhook(...) instead, see Receiving Inbound Email. Reach for handleWebhookEvent the moment you also care about payments or interactions.
Why the header matters#
The X-Webhook-Event header is the only discriminator present on every webhook family, so it is what the SDK keys on. Primitive names every delivery in that header, not in the body. The stored body is sent verbatim with no shared envelope, so its shape differs by family:
| Family | Discriminator location | Example |
|---|---|---|
email.* | body field event | { "event": "email.received", ... } |
payment.* | body field type | { "type": "payment.settled", ... } |
interaction.* | none, body has no event/type field | { "interaction": { ... } } |
Because interaction.* bodies carry no discriminator at all, the header is the only reliable signal across every family. handleWebhookEvent reads it for you and overlays a canonical event field onto the parsed result so your code always branches on one field, regardless of family.
Verify and classify in one call#
handleWebhookEvent verifies the Primitive-Signature HMAC over the raw body first (this step is identical to webhook signature verification and independent of the event family), then parses the JSON and classifies it on the X-Webhook-Event header.
- 1
Import the handler and type guards#
import { handleWebhookEvent, isPaymentSettledEvent, isInteractionX402Event, } from "@primitivedotdev/sdk/webhook"; - 2
Call handleWebhookEvent with the raw body, headers, and secret#
const event = handleWebhookEvent({ body: rawBodyString, headers: req.headers, secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, });bodymust be the exact request bytes before any JSON parsing.headersaccepts a plain object (Expressreq.headers) or a Fetch APIHeadersinstance.secretis your account's webhook secret, returned byGET /account/webhook-secret. - 3
Branch on the typed event#
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)) { // typed interaction.x402.* event (challenge/payment/settled/...) console.log(event.event, event.interaction); } else if (event.event === "email.received") { // fully typed EmailReceivedEvent — see the email-model docs console.log(event.email.headers.subject); } else { // UnknownEvent: a future event type this SDK version doesn't know about yet console.log("unhandled event:", event.event); }
Expected result: for a known event type, event is a typed value matching that family's shape; for anything the current SDK doesn't recognize, event is an UnknownEvent with event and the raw payload preserved, the call never throws for an unrecognized (but well-signed) event type.
The event catalog#
The SDK classifies 19 header values across four families, exported as the WEBHOOK_EVENT_TYPES array and the WebhookEventType union:
import { WEBHOOK_EVENT_TYPES } from "@primitivedotdev/sdk/webhook";
| Family | Event names |
|---|---|
email.received, email.bounced, email.tls_report, email.dmarc_report, email.dmarc_failure | |
| Payment | payment.settled, payment.failed |
| Interaction (x402) | interaction.x402.challenge, interaction.x402.payment, interaction.x402.settled, interaction.x402.rejected, interaction.x402.declined, interaction.x402.expired, interaction.x402.verify_timeout |
| Interaction (ack) | interaction.ack.received, interaction.ack.requested, interaction.ack.acked, interaction.ack.canceled, interaction.ack.expired |
Only email.received is validated against the canonical JSON Schema and returned as a fully typed EmailReceivedEvent. Every other known type is returned as its body plus a canonical event field overlaid from the header. See Webhook Events Overview for the cross-SDK contract and forward-compatibility guarantees this catalog is part of.
Typed shapes for payment and interaction events#
PaymentEvent (and its narrower PaymentSettledEvent / PaymentFailedEvent variants) carries flat fields, no nested payment object:
import type { PaymentEvent, PaymentSettledEvent, PaymentFailedEvent } from "@primitivedotdev/sdk/webhook";
challenge_id, the x402 payment challenge this event settles or failsamount, token base units (USDC has 6 decimals, so"10000"is 0.01 USDC)settle_tx, present onpayment.settled, the on-chain settlement transaction hashfailure_reason, present onpayment.failed
InteractionX402Event wraps the raw { interaction: {...} } body plus the canonical event name from the header:
import type { InteractionX402Event } from "@primitivedotdev/sdk/webhook";
Use isPaymentEvent, isPaymentSettledEvent, isPaymentFailedEvent, and isInteractionX402Event as type guards to narrow WebhookEvent without manually checking event strings.
handleWebhookEvent vs. handleWebhook#
handleWebhook remains hard-typed to email.received for backward compatibility, it throws on anything else. handleWebhookEvent is the superset: same signature verification, but it returns the full WebhookEvent union instead of throwing on a payment or interaction delivery.
If your handler is already committed to email.received only and never expects payment or interaction traffic on that endpoint, handleWebhook (used by primitive.receive(...)) is simpler and still correct. Switch to handleWebhookEvent the moment you register the same endpoint for x402 payments.
Don't try to discriminate on a body field yourself. interaction.* bodies have no event or type field at all, code that reads body.event will silently misclassify or crash on those deliveries. Always let handleWebhookEvent read the X-Webhook-Event header for you.
Next steps#
Understand the challenge/pay/settle model that produces payment.* and interaction.x402.* events.
Webhook Signature VerificationVerify the Primitive-Signature HMAC header manually when you don't have a standard Request object.
Node.js SDK ErrorsLook up WebhookVerificationError, WebhookValidationError, and WebhookPayloadError codes.
Webhook Events OverviewSee the shared event catalog and forward-compatibility contract across every SDK.
Was this page helpful?