Webhook Payload
Primitive delivers inbound mail as an event in the email.* family. The most common is email.received; machine-generated mail (delivery failures, TLS and DMARC reports) is delivered under a more specific event type instead. All email.* events share the same payload shape, and the same shape is sent to self-hosted webhooks and hosted Primitive Functions.
The event name is delivered in the X-Webhook-Event request header, and the delivery id in X-Webhook-Id. Always read the event type from X-Webhook-Event (or the top-level event field) rather than assuming email.received. email.received is documented in full below; the other email.* events (next section) reuse the same fields, and the x402 payment and interaction events (at the end of this page) have their own payload shapes.
Email Event Types
Every inbound message is delivered under one of the email.* event types below. The type is resolved from the kind of message received: a delivery failure (DSN) arrives as email.bounced, an SMTP TLS report as email.tls_report, and so on. Anything that isn't one of these specialized kinds — ordinary mail, auto-replies, complaints — is delivered as email.received.
| Event | When it fires |
|---|---|
email.received | Regular inbound mail and any kind that isn't one of the specialized types below. |
email.bounced | A delivery failure / DSN (delivery status notification) for one of your sends. |
email.tls_report | An SMTP TLS report (RFC 8460) addressed to your domain. |
email.dmarc_report | A DMARC aggregate report (RFC 7489) addressed to your domain. |
email.dmarc_failure | A DMARC failure (forensic / RUF) report addressed to your domain. |
All email.* events use the same payload documented below. The email.bounced, email.tls_report, and email.dmarc_report types additionally carry a matching block under email.analysis (see Analysis) with the parsed report or bounce details. email.dmarc_failure carries no extra analysis block.
An endpoint receives only the event types it is subscribed to. Endpoints created before subscriptions existed, and endpoints that leave the subscription unset, receive every email.* event. To receive email.bounced (or any other specialized type) on a newer endpoint, subscribe it via the endpoint's rules.event_types. See Endpoints.
Top-Level Shape
This is the actual email.received payload shape Primitive sends to webhook endpoints and Primitive Functions.
{ "id": "evt_0d4ce769a6f5822afa37417473d33ff8ae0254f280802d94f4eff284cde30567", "event": "email.received", "version": "2025-12-14", "delivery": { "endpoint_id": "ep_01HZYABCDEF1234567890", "attempt": 1, "attempted_at": "2026-01-01T00:00:05.000Z" }, "email": { "id": "em_01HZYABCDEF1234567890", "received_at": "2026-01-01T00:00:00.000Z", "smtp": { "helo": "mail.example.com", "mail_from": "bounce@example.com", "rcpt_to": [ "support@your-org.primitive.email" ] }, "headers": { "message_id": "<message-id@example.com>", "subject": "Need help", "from": "Alice <alice@example.com>", "to": "Support <support@your-org.primitive.email>", "date": "Thu, 01 Jan 2026 00:00:00 +0000" }, "content": { "raw": { "included": true, "encoding": "base64", "max_inline_bytes": 262144, "size_bytes": 257, "sha256": "8bf8f3c56544d3e56ac84f414754674d51c55d6504d847a9868af46ab65d4f0f", "data": "RnJvbTogQWxpY2UgPGFsaWNlQGV4YW1wbGUuY29tPg0KVG86IFN1cHBvcnQgPHN1cHBvcnRAeW91ci1vcmcucHJpbWl0aXZlLmVtYWlsPg0KQ2M6IEJvYiA8Ym9iQGV4YW1wbGUuY29tPg0KUmVwbHktVG86IEFsaWNlIFN1cHBvcnQgPHJlcGx5QGV4YW1wbGUuY29tPg0KU3ViamVjdDogTmVlZCBoZWxwDQpEYXRlOiBUaHUsIDAxIEphbiAyMDI2IDAwOjAwOjAwICswMDAwDQpNZXNzYWdlLUlEOiA8bWVzc2FnZS1pZEBleGFtcGxlLmNvbT4NCg0KSGVsbG8=" }, "download": { "url": "https://api.primitive.dev/v1/emails/em_01HZYABCDEF1234567890/raw?token=download-token", "expires_at": "2026-01-02T00:00:00.000Z" } }, "parsed": { "status": "complete", "error": null, "body_text": "Hello", "body_html": "<p>Hello</p>", "reply_to": [ { "address": "reply@example.com", "name": "Alice Support" } ], "cc": [ { "address": "bob@example.com", "name": "Bob" } ], "bcc": null, "to_addresses": [ { "address": "support@your-org.primitive.email", "name": "Support" } ], "in_reply_to": [ "<previous@example.com>" ], "references": [ "<root@example.com>", "<previous@example.com>" ], "attachments": [ { "filename": "invoice.pdf", "content_type": "application/pdf", "size_bytes": 24576, "sha256": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "part_index": 0, "tar_path": "0_invoice.pdf" } ], "attachments_download_url": "https://api.primitive.dev/v1/emails/em_01HZYABCDEF1234567890/attachments.tar.gz?token=download-token" }, "analysis": { "spamassassin": { "score": 0.1 }, "sender": { "authenticated": true, "basis": "dmarc_aligned", "reasons": [ "DKIM signature aligned with the From domain" ] } }, "auth": { "spf": "pass", "dmarc": "pass", "dmarcPolicy": "reject", "dmarcFromDomain": "example.com", "dmarcSpfAligned": false, "dmarcDkimAligned": true, "dmarcSpfStrict": false, "dmarcDkimStrict": false, "dkimSignatures": [ { "domain": "example.com", "selector": "default", "result": "pass", "aligned": true, "keyBits": 2048, "algo": "rsa-sha256" } ] }, "thread_id": "thr_01HZYABCDEF1234567890" } }
Stable Identifiers
The top-level id is stable across retries and manual replays to the same endpoint. Use it for webhook dedupe.
email.id is the stored email id. Use it with REST endpoints such as /v1/emails/{id}, /v1/emails/{id}/replay, and /v1/emails/{id}/reply.
Delivery Metadata
The delivery object records the delivery attempt counter and endpoint information. delivery.attempt is a delivery-call counter; in the current delivery path it is 1 on the first delivery to an endpoint. Do not rely on it to distinguish a first delivery from a retry — the top-level id is preserved across retries and re-deliveries, so use it (not attempt) for dedupe.
Parsed Content
When parsing succeeds, email.parsed.status is complete and body fields are available. Large raw content and attachments can be stored separately and exposed through signed download URLs.
If parsing fails, email.parsed.status is failed, email.parsed.error is populated, body and address fields are null, and attachments are empty or partial metadata only.
Headers
Headers preserve the important RFC 5322 fields needed for audit trails. Use email.headers.message_id, email.parsed.in_reply_to, email.parsed.references, and email.thread_id when building threading logic.
Raw Content
Small raw emails are included inline at email.content.raw.data as base64. If the raw email is larger than email.content.raw.max_inline_bytes, email.content.raw.included is false and the raw content must be fetched from email.content.download.url before expires_at.
Attachments are listed as metadata in email.parsed.attachments. Download the files from email.parsed.attachments_download_url when it is not null.
Auth And Analysis
email.auth contains SPF, DMARC, and DKIM results from the inbound message. email.analysis.sender is Primitive's sender authentication verdict derived from those auth fields. email.analysis.spamassassin is present when spam scoring ran. email.analysis.forward is optional; in managed Primitive webhook deliveries it is currently included only when forward analysis detects forwarded content.
Specialized event types attach an additional block under email.analysis with the parsed details: email.analysis.bounce on an email.bounced event (the classified delivery failure), email.analysis.tls_report on an email.tls_report event (the parsed RFC 8460 report), and email.analysis.dmarc_report on an email.dmarc_report event (the parsed RFC 7489 aggregate report). Each is present only when the corresponding report or bounce parsed successfully.
Field Reference
These descriptions are sourced from the webhook JSON schema used by the SDK, with the managed email.analysis.sender extension documented from the webhook delivery implementation. Required means required when its parent object or union variant is present.
Top Level
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique delivery event ID. |
event | EmailEventType | yes | Event type identifier: one of "email.received", "email.bounced", "email.tls_report", "email.dmarc_report", or "email.dmarc_failure". Matches the X-Webhook-Event header. See Email Event Types. |
version | WebhookVersion | yes | API version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK. |
delivery | object | yes | Metadata about this webhook delivery. |
email | object | yes | The email that triggered this event. |
routing | object | no | The recipient-routing decision for this email: which endpoint it resolved to and why. Present only when recipient routing is enabled for your organization; omitted otherwise. See Routing. |
Delivery
| Field | Type | Required | Description |
|---|---|---|---|
delivery.endpoint_id | string | yes | ID of the webhook endpoint receiving this event. Matches the endpoint ID from your Primitive dashboard. |
delivery.attempt | integer | yes | Delivery-call counter for this email. In the current delivery path it is 1 on an endpoint's first delivery. Do not treat it as a precise per-endpoint retry count or use it to detect a first delivery; dedupe on the top-level id instead. |
delivery.attempted_at | string | yes | ISO 8601 timestamp (UTC) when this delivery was attempted. |
Routing
Present only when recipient routing is enabled for your organization; omitted otherwise. It is the same decision recorded on the email and returned by /v1/routes/simulate, so you can see why a message reached this endpoint without a separate call. Only outcome is guaranteed to be present; treat the other fields as optional.
| Field | Type | Required | Description |
|---|---|---|---|
routing.outcome | "matched" | "defaulted" | "none" | yes | How the destination was chosen: matched a recipient route, fell back to a default destination, or none resolved. |
routing.endpoint_id | string | null | no | The endpoint the email was delivered to, or null when none resolved. |
routing.matched_route_id | string | null | no | The recipient route that matched, or null when the outcome was not matched. |
routing.matched_tier | "exact" | "wildcard" | "regex" | null | no | The match tier of the route that matched, or null when the outcome was not matched. |
routing.default_scope | "domain" | "org" | null | no | When the outcome was defaulted, whether the default came from the domain or the organization; null otherwise. |
routing.version | integer | no | Decision schema version, so you can detect shape changes. |
Email And SMTP
| Field | Type | Required | Description |
|---|---|---|---|
email.id | string | yes | Unique email ID in Primitive. Use this ID when calling Primitive APIs to reference this email. |
email.thread_id | string | null | no | Conversation thread this email belongs to. Inbound and outbound messages in the same conversation share a thread_id. Null on messages received before threading was enabled. |
email.received_at | string | yes | ISO 8601 timestamp (UTC) when Primitive received the email. |
email.smtp | object | yes | SMTP envelope information. This is the real sender and recipient info from the SMTP transaction, which may differ from headers. |
email.smtp.helo | string | null | yes | HELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction. |
email.smtp.mail_from | string | yes | SMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header. |
email.smtp.rcpt_to | string[] | yes | SMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery. |
email.headers | object | yes | Parsed email headers. These are extracted from the email content, not the SMTP envelope. |
email.headers.message_id | string | null | yes | Message-ID header value. Null if the email had no Message-ID header. |
email.headers.subject | string | null | yes | Subject header value. Null if the email had no Subject header. |
email.headers.from | string | yes | From header value. May include display name: "John Doe" <john@example.com> |
email.headers.to | string | yes | To header value. May include multiple addresses or display names. |
email.headers.date | string | null | yes | Date header value as it appeared in the email. Null if the email had no Date header. |
Raw Content
| Field | Type | Required | Description |
|---|---|---|---|
email.content | object | yes | Raw email content and download information. |
email.content.raw | RawContent | yes | Raw email in RFC 5322 format. May be inline (base64) or download-only depending on size. |
email.content.raw.included | boolean | yes | Whether the raw content is included inline. true means data is present. false means download is required. |
email.content.raw.encoding | "base64" | when inline | Encoding used for the data field. Always "base64". |
email.content.raw.reason_code | "size_exceeded" | when download-only | Reason the content was not included inline. |
email.content.raw.max_inline_bytes | integer | yes | Maximum size in bytes for inline inclusion. Emails larger than this threshold require download. |
email.content.raw.size_bytes | integer | yes | Actual size of the raw email in bytes. |
email.content.raw.sha256 | string | yes | SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after base64 decoding or download. |
email.content.raw.data | string | when inline | Base64-encoded raw email (RFC 5322 format). Decode with Buffer.from(data, 'base64') in Node.js. |
email.content.download | object | yes | Download information for the raw email. Always present, even if raw content is inline. |
email.content.download.url | string | yes | URL to download the raw email as-is in RFC 5322 format. Managed Primitive always issues HTTPS. |
email.content.download.expires_at | string | yes | ISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 403. |
Parsed Content
| Field | Type | Required | Description |
|---|---|---|---|
email.parsed | ParsedData | yes | Parsed email content (body text, HTML, attachments). Check status to determine if parsing succeeded. |
email.parsed.status | "complete" | "failed" | yes | Discriminant indicating whether parsing succeeded. |
email.parsed.error | ParsedError | null | yes | Always null when parsing succeeds. Contains failure details when parsing fails. |
email.parsed.error.code | "PARSE_FAILED" | "ATTACHMENT_EXTRACTION_FAILED" | when failed | Error code indicating the type of failure. |
email.parsed.error.message | string | when failed | Human-readable error message describing what went wrong. |
email.parsed.error.retryable | boolean | when failed | Whether retrying might succeed. If true, the error was transient. If false, the email itself is problematic. |
email.parsed.body_text | string | null | yes | Plain text body of the email. Null if the email had no text/plain part or parsing failed. |
email.parsed.body_html | string | null | yes | HTML body of the email. Null if the email had no text/html part or parsing failed. |
email.parsed.reply_to | EmailAddress[] | null | yes | Parsed Reply-To header addresses. Null if the email had no Reply-To header or parsing failed. |
email.parsed.cc | EmailAddress[] | null | yes | Parsed CC header addresses. Null if the email had no CC header or parsing failed. |
email.parsed.bcc | EmailAddress[] | null | yes | Parsed BCC header addresses. Null if the email had no BCC header or parsing failed. |
email.parsed.to_addresses | EmailAddress[] | null | yes | Parsed To header addresses. Null if the email had no To header or parsing failed. |
email.parsed.reply_to[].address | string | yes | The email address portion. This is the raw value from the email header with no validation applied. |
email.parsed.reply_to[].name | string | null | yes | The display name portion, if present. Null if the address had no display name. |
email.parsed.cc[].address | string | yes | The email address portion. |
email.parsed.cc[].name | string | null | yes | The display name portion, if present. |
email.parsed.bcc[].address | string | yes | The email address portion. |
email.parsed.bcc[].name | string | null | yes | The display name portion, if present. |
email.parsed.to_addresses[].address | string | yes | The email address portion. |
email.parsed.to_addresses[].name | string | null | yes | The display name portion, if present. |
email.parsed.in_reply_to | string[] | null | yes | In-Reply-To header values (Message-IDs of the email or emails being replied to). |
email.parsed.references | string[] | null | yes | References header values (Message-IDs of the email thread). |
email.parsed.attachments | WebhookAttachment[] | yes | List of attachments with metadata. May contain partial attachment metadata even when parsing failed. |
email.parsed.attachments[].filename | string | null | yes | Original filename from the email. May be null if the attachment had no filename specified. |
email.parsed.attachments[].content_type | string | yes | MIME content type, for example "application/pdf" or "image/png". |
email.parsed.attachments[].size_bytes | integer | yes | Size of the attachment in bytes. |
email.parsed.attachments[].sha256 | string | yes | SHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download. |
email.parsed.attachments[].part_index | integer | yes | Zero-based index of this part in the MIME structure. |
email.parsed.attachments[].tar_path | string | yes | Path to this attachment within the downloaded tar.gz archive. |
email.parsed.attachments_download_url | string | null | yes | URL to download all attachments as a tar.gz archive. Null if the email had no attachments or parsing failed. |
Auth
| Field | Type | Required | Description |
|---|---|---|---|
email.auth | EmailAuth | yes | Email authentication results for SPF, DKIM, and DMARC. |
email.auth.spf | SpfResult | yes | SPF verification result. |
email.auth.dmarc | DmarcResult | yes | DMARC verification result. |
email.auth.dmarcPolicy | DmarcPolicy | yes | DMARC policy from the sender's DNS record. |
email.auth.dmarcFromDomain | string | null | yes | The organizational domain used for DMARC lookups. |
email.auth.dmarcSpfAligned | boolean | yes | Whether SPF aligned with the From domain for DMARC purposes. |
email.auth.dmarcDkimAligned | boolean | yes | Whether DKIM aligned with the From domain for DMARC purposes. |
email.auth.dmarcSpfStrict | boolean | null | yes | Whether DMARC SPF alignment mode is strict. |
email.auth.dmarcDkimStrict | boolean | null | yes | Whether DMARC DKIM alignment mode is strict. |
email.auth.dkimSignatures | DkimSignature[] | yes | All DKIM signatures found in the email with their verification results. |
email.auth.dkimSignatures[].domain | string | yes | The domain that signed this DKIM signature (d= tag). This may differ from the From domain. |
email.auth.dkimSignatures[].selector | string | null | yes | The DKIM selector used to locate the public key (s= tag). |
email.auth.dkimSignatures[].result | DkimResult | yes | Verification result for this specific signature. |
email.auth.dkimSignatures[].aligned | boolean | yes | Whether this signature's domain aligns with the From domain for DMARC. |
email.auth.dkimSignatures[].keyBits | integer | null | yes | Key size in bits. Null if the key size could not be determined. |
email.auth.dkimSignatures[].algo | string | null | yes | Signing algorithm, for example "rsa-sha256" or "ed25519-sha256". |
Analysis
| Field | Type | Required | Description |
|---|---|---|---|
email.analysis | EmailAnalysis | yes | Email analysis and classification results. Fields may be absent when that analysis was not performed. |
email.analysis.spamassassin | object | no | SpamAssassin analysis results. Present when spam scoring ran. |
email.analysis.spamassassin.score | number | yes | Overall spam score. Higher scores indicate higher likelihood of spam. |
email.analysis.sender | object | no | Primitive managed sender authentication verdict derived from email.auth. |
email.analysis.sender.authenticated | boolean | yes | True when the From domain proved control of the send through aligned SPF, aligned DKIM, or provider attestation. |
email.analysis.sender.basis | "dmarc_aligned" | "google_provider_attested" | "spf_aligned" | "unauthenticated" | yes | The public basis for the sender authentication verdict. |
email.analysis.sender.reasons | string[] | yes | Short factual reasons for the sender authentication verdict. |
email.analysis.bounce | BounceAnalysis | no | Classified delivery failure (DSN) details. Present on email.bounced events when the bounce parsed. |
email.analysis.tls_report | TlsReportAnalysis | no | Parsed SMTP TLS report (RFC 8460). Present on email.tls_report events when the report parsed. |
email.analysis.dmarc_report | DmarcReportAnalysis | no | Parsed DMARC aggregate report (RFC 7489). Present on email.dmarc_report events when the report parsed. |
email.analysis.forward | ForwardAnalysis | no | Forward detection and analysis results. In managed Primitive webhook deliveries, this object is included only when forwarded content is detected. |
email.analysis.forward.detected | boolean | yes | Whether any forwards were detected in the email. In managed Primitive webhook deliveries, this is true when the object is present. |
email.analysis.forward.results | ForwardResult[] | yes | Analysis results for each detected forward. |
email.analysis.forward.results[].type | "inline" | "attachment" | yes | Whether the detected forward was inline or in an attachment. |
email.analysis.forward.results[].attachment_tar_path | string | when attachment | Path to the attachment in the attachments tar archive. |
email.analysis.forward.results[].attachment_filename | string | null | when attachment | Original filename of the attachment, if available. |
email.analysis.forward.results[].analyzed | boolean | when attachment | Whether this attachment was analyzed. |
email.analysis.forward.results[].original_sender | ForwardOriginalSender | null | yes | Original sender of the forwarded email, if extractable. |
email.analysis.forward.results[].original_sender.email | string | yes | Email address of the original sender. |
email.analysis.forward.results[].original_sender.domain | string | yes | Domain of the original sender. |
email.analysis.forward.results[].verification | ForwardVerification | null | yes | Verification result for the forwarded email. Null when an attachment forward was not analyzed. |
email.analysis.forward.results[].verification.verdict | ForwardVerdict | yes | Overall verdict on whether the forward is authentic. |
email.analysis.forward.results[].verification.confidence | AuthConfidence | yes | Confidence level for this verdict. |
email.analysis.forward.results[].verification.dkim_verified | boolean | yes | Whether a valid DKIM signature was found that verifies the original sender. |
email.analysis.forward.results[].verification.dkim_domain | string | null | yes | Domain of the DKIM signature that verified the forward, if any. |
email.analysis.forward.results[].verification.dmarc_policy | DmarcPolicy | yes | DMARC policy of the original sender's domain. |
email.analysis.forward.results[].summary | string | yes | Human-readable summary of the forward analysis. |
email.analysis.forward.attachments_found | integer | yes | Total number of .eml attachments found. |
email.analysis.forward.attachments_analyzed | integer | yes | Number of .eml attachments that were analyzed. |
email.analysis.forward.attachments_limit | integer | null | yes | Maximum number of attachments that will be analyzed, or null if unlimited. |
x402 Payment And Interaction Events
x402 settlements deliver their own events, separate from email.received. The event name is in the X-Webhook-Event header. Subscribe an endpoint to these names (or accept all events) to receive them. x402 is in an invite-only soft launch; see Collecting Payments and x402 over Email.
Payment events
payment.settled and payment.failed fire for BOTH the synthetic API pay flow and the email-native settle flow, with an identical payload shape. For email-native settlements payer_org is null because the payer is off-net.
The payee (the org that issued the challenge and receives the funds) always gets the event, with direction set to received. When the payer is also an on-net org (the synthetic API pay flow, where payer_org is non-null), that org ALSO gets its own copy of the same outcome, with direction set to sent. This gives an org that auto-pays a challenge an asynchronous settlement channel of its own, so it can reconcile a payment it made without relying on the synchronous response of the pay call. When the payer is off-net (email-native settlements), only the payee's received event is emitted.
payment.settled as seen by the payee (direction: "received"):
{ "type": "payment.settled", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": "22222222-2222-4222-8222-222222222222", "direction": "received", "settle_tx": "0x..." }
The same settlement as seen by the on-net payer (direction: "sent"):
{ "type": "payment.settled", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": "22222222-2222-4222-8222-222222222222", "direction": "sent", "settle_tx": "0x..." }
payment.failed carries failure_reason instead of settle_tx, and likewise reaches both sides when the payer is on-net:
{ "type": "payment.failed", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": null, "direction": "received", "failure_reason": "settle_daily_cap_exceeded" }
| Field | Type | Description |
|---|---|---|
type | "payment.settled" | "payment.failed" | The event type; also the X-Webhook-Event header value. |
challenge_id | string | The challenge that settled or failed. |
network | string | base or base-sepolia. |
amount | string | Amount in token base units (USDC has 6 decimals). |
asset | string | The token contract (checksummed). |
payer_org | string | null | The paying org for synthetic payments, null for email-native (off-net payer). |
direction | "received" | "sent" | Which side of the payment this delivery is for. received is the payee's copy; sent is the on-net payer's copy. An off-net payer never receives a sent event. |
settle_tx | string | On-chain settlement tx hash. Present on payment.settled only. |
failure_reason | string | Why settlement failed. Present on payment.failed only. |
Interaction events (email-native)
Each accepted step of an email-native x402 interaction also emits a protocol-specific interaction.x402.* event: interaction.x402.settled for a receipt step, interaction.x402.rejected for a reject, plus interaction.x402.challenge, interaction.x402.payment, interaction.x402.declined, interaction.x402.expired, and interaction.x402.verify_timeout for the other steps. The payload is a snapshot of the interaction at that step:
{ "interaction": { "id": "...", "wire_id": "<uuid>@agent.payee.example", "protocol": "x402.payment", "protocol_version": 1, "state": "completed", "role": "initiator", "awaiting": null, "counterparty_address": "wallet@agent.payer.example", "counterparty_domain": "agent.payer.example", "our_address": "billing@agent.payee.example", "step": "receipt" } }
A settled email-native payment therefore delivers both an interaction.x402.settled event (the step) and a payment.settled event (the uniform settlement notification); a rejected one delivers interaction.x402.rejected and payment.failed. They complement each other: subscribe to payment.* for a uniform settlement signal across both rails, or to interaction.x402.* for step-level detail.
Security
Every webhook includes a Primitive-Signature header. Verify it before trusting the payload. See Signature Verification.