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.

EventWhen it fires
email.receivedRegular inbound mail and any kind that isn't one of the specialized types below.
email.bouncedA delivery failure / DSN (delivery status notification) for one of your sends.
email.tls_reportAn SMTP TLS report (RFC 8460) addressed to your domain.
email.dmarc_reportA DMARC aggregate report (RFC 7489) addressed to your domain.
email.dmarc_failureA 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

FieldTypeRequiredDescription
idstringyesUnique delivery event ID.
eventEmailEventTypeyesEvent 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.
versionWebhookVersionyesAPI version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK.
deliveryobjectyesMetadata about this webhook delivery.
emailobjectyesThe email that triggered this event.
routingobjectnoThe 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

FieldTypeRequiredDescription
delivery.endpoint_idstringyesID of the webhook endpoint receiving this event. Matches the endpoint ID from your Primitive dashboard.
delivery.attemptintegeryesDelivery-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_atstringyesISO 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.

FieldTypeRequiredDescription
routing.outcome"matched" | "defaulted" | "none"yesHow the destination was chosen: matched a recipient route, fell back to a default destination, or none resolved.
routing.endpoint_idstring | nullnoThe endpoint the email was delivered to, or null when none resolved.
routing.matched_route_idstring | nullnoThe recipient route that matched, or null when the outcome was not matched.
routing.matched_tier"exact" | "wildcard" | "regex" | nullnoThe match tier of the route that matched, or null when the outcome was not matched.
routing.default_scope"domain" | "org" | nullnoWhen the outcome was defaulted, whether the default came from the domain or the organization; null otherwise.
routing.versionintegernoDecision schema version, so you can detect shape changes.

Email And SMTP

FieldTypeRequiredDescription
email.idstringyesUnique email ID in Primitive. Use this ID when calling Primitive APIs to reference this email.
email.thread_idstring | nullnoConversation 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_atstringyesISO 8601 timestamp (UTC) when Primitive received the email.
email.smtpobjectyesSMTP envelope information. This is the real sender and recipient info from the SMTP transaction, which may differ from headers.
email.smtp.helostring | nullyesHELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction.
email.smtp.mail_fromstringyesSMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header.
email.smtp.rcpt_tostring[]yesSMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery.
email.headersobjectyesParsed email headers. These are extracted from the email content, not the SMTP envelope.
email.headers.message_idstring | nullyesMessage-ID header value. Null if the email had no Message-ID header.
email.headers.subjectstring | nullyesSubject header value. Null if the email had no Subject header.
email.headers.fromstringyesFrom header value. May include display name: "John Doe" <john@example.com>
email.headers.tostringyesTo header value. May include multiple addresses or display names.
email.headers.datestring | nullyesDate header value as it appeared in the email. Null if the email had no Date header.

Raw Content

FieldTypeRequiredDescription
email.contentobjectyesRaw email content and download information.
email.content.rawRawContentyesRaw email in RFC 5322 format. May be inline (base64) or download-only depending on size.
email.content.raw.includedbooleanyesWhether the raw content is included inline. true means data is present. false means download is required.
email.content.raw.encoding"base64"when inlineEncoding used for the data field. Always "base64".
email.content.raw.reason_code"size_exceeded"when download-onlyReason the content was not included inline.
email.content.raw.max_inline_bytesintegeryesMaximum size in bytes for inline inclusion. Emails larger than this threshold require download.
email.content.raw.size_bytesintegeryesActual size of the raw email in bytes.
email.content.raw.sha256stringyesSHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after base64 decoding or download.
email.content.raw.datastringwhen inlineBase64-encoded raw email (RFC 5322 format). Decode with Buffer.from(data, 'base64') in Node.js.
email.content.downloadobjectyesDownload information for the raw email. Always present, even if raw content is inline.
email.content.download.urlstringyesURL to download the raw email as-is in RFC 5322 format. Managed Primitive always issues HTTPS.
email.content.download.expires_atstringyesISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 403.

Parsed Content

FieldTypeRequiredDescription
email.parsedParsedDatayesParsed email content (body text, HTML, attachments). Check status to determine if parsing succeeded.
email.parsed.status"complete" | "failed"yesDiscriminant indicating whether parsing succeeded.
email.parsed.errorParsedError | nullyesAlways null when parsing succeeds. Contains failure details when parsing fails.
email.parsed.error.code"PARSE_FAILED" | "ATTACHMENT_EXTRACTION_FAILED"when failedError code indicating the type of failure.
email.parsed.error.messagestringwhen failedHuman-readable error message describing what went wrong.
email.parsed.error.retryablebooleanwhen failedWhether retrying might succeed. If true, the error was transient. If false, the email itself is problematic.
email.parsed.body_textstring | nullyesPlain text body of the email. Null if the email had no text/plain part or parsing failed.
email.parsed.body_htmlstring | nullyesHTML body of the email. Null if the email had no text/html part or parsing failed.
email.parsed.reply_toEmailAddress[] | nullyesParsed Reply-To header addresses. Null if the email had no Reply-To header or parsing failed.
email.parsed.ccEmailAddress[] | nullyesParsed CC header addresses. Null if the email had no CC header or parsing failed.
email.parsed.bccEmailAddress[] | nullyesParsed BCC header addresses. Null if the email had no BCC header or parsing failed.
email.parsed.to_addressesEmailAddress[] | nullyesParsed To header addresses. Null if the email had no To header or parsing failed.
email.parsed.reply_to[].addressstringyesThe email address portion. This is the raw value from the email header with no validation applied.
email.parsed.reply_to[].namestring | nullyesThe display name portion, if present. Null if the address had no display name.
email.parsed.cc[].addressstringyesThe email address portion.
email.parsed.cc[].namestring | nullyesThe display name portion, if present.
email.parsed.bcc[].addressstringyesThe email address portion.
email.parsed.bcc[].namestring | nullyesThe display name portion, if present.
email.parsed.to_addresses[].addressstringyesThe email address portion.
email.parsed.to_addresses[].namestring | nullyesThe display name portion, if present.
email.parsed.in_reply_tostring[] | nullyesIn-Reply-To header values (Message-IDs of the email or emails being replied to).
email.parsed.referencesstring[] | nullyesReferences header values (Message-IDs of the email thread).
email.parsed.attachmentsWebhookAttachment[]yesList of attachments with metadata. May contain partial attachment metadata even when parsing failed.
email.parsed.attachments[].filenamestring | nullyesOriginal filename from the email. May be null if the attachment had no filename specified.
email.parsed.attachments[].content_typestringyesMIME content type, for example "application/pdf" or "image/png".
email.parsed.attachments[].size_bytesintegeryesSize of the attachment in bytes.
email.parsed.attachments[].sha256stringyesSHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download.
email.parsed.attachments[].part_indexintegeryesZero-based index of this part in the MIME structure.
email.parsed.attachments[].tar_pathstringyesPath to this attachment within the downloaded tar.gz archive.
email.parsed.attachments_download_urlstring | nullyesURL to download all attachments as a tar.gz archive. Null if the email had no attachments or parsing failed.

Auth

FieldTypeRequiredDescription
email.authEmailAuthyesEmail authentication results for SPF, DKIM, and DMARC.
email.auth.spfSpfResultyesSPF verification result.
email.auth.dmarcDmarcResultyesDMARC verification result.
email.auth.dmarcPolicyDmarcPolicyyesDMARC policy from the sender's DNS record.
email.auth.dmarcFromDomainstring | nullyesThe organizational domain used for DMARC lookups.
email.auth.dmarcSpfAlignedbooleanyesWhether SPF aligned with the From domain for DMARC purposes.
email.auth.dmarcDkimAlignedbooleanyesWhether DKIM aligned with the From domain for DMARC purposes.
email.auth.dmarcSpfStrictboolean | nullyesWhether DMARC SPF alignment mode is strict.
email.auth.dmarcDkimStrictboolean | nullyesWhether DMARC DKIM alignment mode is strict.
email.auth.dkimSignaturesDkimSignature[]yesAll DKIM signatures found in the email with their verification results.
email.auth.dkimSignatures[].domainstringyesThe domain that signed this DKIM signature (d= tag). This may differ from the From domain.
email.auth.dkimSignatures[].selectorstring | nullyesThe DKIM selector used to locate the public key (s= tag).
email.auth.dkimSignatures[].resultDkimResultyesVerification result for this specific signature.
email.auth.dkimSignatures[].alignedbooleanyesWhether this signature's domain aligns with the From domain for DMARC.
email.auth.dkimSignatures[].keyBitsinteger | nullyesKey size in bits. Null if the key size could not be determined.
email.auth.dkimSignatures[].algostring | nullyesSigning algorithm, for example "rsa-sha256" or "ed25519-sha256".

Analysis

FieldTypeRequiredDescription
email.analysisEmailAnalysisyesEmail analysis and classification results. Fields may be absent when that analysis was not performed.
email.analysis.spamassassinobjectnoSpamAssassin analysis results. Present when spam scoring ran.
email.analysis.spamassassin.scorenumberyesOverall spam score. Higher scores indicate higher likelihood of spam.
email.analysis.senderobjectnoPrimitive managed sender authentication verdict derived from email.auth.
email.analysis.sender.authenticatedbooleanyesTrue 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"yesThe public basis for the sender authentication verdict.
email.analysis.sender.reasonsstring[]yesShort factual reasons for the sender authentication verdict.
email.analysis.bounceBounceAnalysisnoClassified delivery failure (DSN) details. Present on email.bounced events when the bounce parsed.
email.analysis.tls_reportTlsReportAnalysisnoParsed SMTP TLS report (RFC 8460). Present on email.tls_report events when the report parsed.
email.analysis.dmarc_reportDmarcReportAnalysisnoParsed DMARC aggregate report (RFC 7489). Present on email.dmarc_report events when the report parsed.
email.analysis.forwardForwardAnalysisnoForward detection and analysis results. In managed Primitive webhook deliveries, this object is included only when forwarded content is detected.
email.analysis.forward.detectedbooleanyesWhether any forwards were detected in the email. In managed Primitive webhook deliveries, this is true when the object is present.
email.analysis.forward.resultsForwardResult[]yesAnalysis results for each detected forward.
email.analysis.forward.results[].type"inline" | "attachment"yesWhether the detected forward was inline or in an attachment.
email.analysis.forward.results[].attachment_tar_pathstringwhen attachmentPath to the attachment in the attachments tar archive.
email.analysis.forward.results[].attachment_filenamestring | nullwhen attachmentOriginal filename of the attachment, if available.
email.analysis.forward.results[].analyzedbooleanwhen attachmentWhether this attachment was analyzed.
email.analysis.forward.results[].original_senderForwardOriginalSender | nullyesOriginal sender of the forwarded email, if extractable.
email.analysis.forward.results[].original_sender.emailstringyesEmail address of the original sender.
email.analysis.forward.results[].original_sender.domainstringyesDomain of the original sender.
email.analysis.forward.results[].verificationForwardVerification | nullyesVerification result for the forwarded email. Null when an attachment forward was not analyzed.
email.analysis.forward.results[].verification.verdictForwardVerdictyesOverall verdict on whether the forward is authentic.
email.analysis.forward.results[].verification.confidenceAuthConfidenceyesConfidence level for this verdict.
email.analysis.forward.results[].verification.dkim_verifiedbooleanyesWhether a valid DKIM signature was found that verifies the original sender.
email.analysis.forward.results[].verification.dkim_domainstring | nullyesDomain of the DKIM signature that verified the forward, if any.
email.analysis.forward.results[].verification.dmarc_policyDmarcPolicyyesDMARC policy of the original sender's domain.
email.analysis.forward.results[].summarystringyesHuman-readable summary of the forward analysis.
email.analysis.forward.attachments_foundintegeryesTotal number of .eml attachments found.
email.analysis.forward.attachments_analyzedintegeryesNumber of .eml attachments that were analyzed.
email.analysis.forward.attachments_limitinteger | nullyesMaximum 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"
}
FieldTypeDescription
type"payment.settled" | "payment.failed"The event type; also the X-Webhook-Event header value.
challenge_idstringThe challenge that settled or failed.
networkstringbase or base-sepolia.
amountstringAmount in token base units (USDC has 6 decimals).
assetstringThe token contract (checksummed).
payer_orgstring | nullThe 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_txstringOn-chain settlement tx hash. Present on payment.settled only.
failure_reasonstringWhy 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.