---
title: "Webhook Payload"
description: "Inbound email event schema, outbound sent_email events, delivery metadata, parsed content fields, and example JSON payloads."
canonical: "https://docs.primitive.dev/docs/webhook-payload"
last-updated: "2026-05-17T00:00:00.000Z"
---

# 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 inbound `email.*` events share the same payload shape, and the same shape is sent to self-hosted webhooks and hosted Primitive Functions.

Mail you send has its own opt-in family, `sent_email.*`, with a different payload. See [Sent Email Events](#sent-email-events).

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.

## 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.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 inbound `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](#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 inbound `email.*` event (but never `sent_email.*` events, which must be listed). To receive `email.bounced` (or any other specialized type) on a newer endpoint, subscribe it via the endpoint's `rules.event_types`. See [Endpoints](/docs/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",
    "automated": false,
    "automated_reasons": []
  }
}
```


## 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 to the same endpoint, so use it (not `attempt`) for dedupe.

## Replays

A replay (`POST /v1/emails/{id}/replay` or `POST /v1/webhooks/deliveries/{id}/replay`) resends the original event's content: `email.headers`, `email.content`, `email.parsed`, `email.auth`, `email.analysis`, `email.thread_id`, `email.automated` and the top-level `routing` are not rebuilt from the email as it is now. Two things depend on the endpoint the replay is sent to, exactly as on a first delivery to it: `email.analysis.understanding` is computed for that endpoint, and an endpoint whose owner may not read part of the event (raw content, for example) gets the event with that part withheld. On a rule-granted delivery replayed to its webhook, `email.analysis.understanding.rules` names the key's rules that granted the email, each as of the version that matched, while the rule is `active` (see [Email Rules](/docs/email-rules#replays)). Only the fields that describe this delivery are refreshed:

- The top-level `id` and `delivery.endpoint_id` describe the endpoint the replay is sent to, so they always agree with the `X-Primitive-Endpoint-Id` header. A replay to the endpoint the original was sent to keeps the original's `id`, so dedupe on `id` keeps working. A replay sent to another endpoint (for a delivery recorded before URL edits kept the endpoint, the endpoint now serving it) carries the `id` a first delivery to that endpoint computes. A URL edit keeps the endpoint, so a replay after one keeps the original's `id`.

- `delivery.attempt` is the replay's own attempt number, the same number its delivery record shows.

- `delivery.attempted_at` is the time of this send.

- Download links (`email.content.download` and `email.parsed.attachments_download_url`) are minted again, with the same permissions and expiry as a first delivery to that endpoint, so they work even after the original links expired. A link the original event did not carry is not added.

`sent_email.*` events are not covered by this contract: their replay keeps the original event id and rebuilds the payload from the send as it is now (see [Endpoints](/docs/endpoints)).

## 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. The SDKs turn these into decisions: `validateEmailAuth(event.email.auth)` computes an overall verdict, and `isTrustedSender(event, { domain })` anchors that verdict to an expected From domain for handlers that gate actions on the sender (a verdict alone does not say which domain authenticated). `email.analysis.sender` is Primitive's sender authentication verdict derived from those auth fields, or from Primitive's own proof that the message was sent through Primitive by the From address. For mail sent from one Primitive address to another and proven only by that proof, `email.analysis.sender.authenticated` can be `true` with basis `primitive_sender` while DKIM, SPF and DMARC did not pass. Endpoint `sender_whitelist` rules, the endpoint rules dry run, and the SDK `isTrustedSender` and `validateEmailAuth` helpers evaluate DKIM, SPF and DMARC only, so they can still treat such a message as unauthenticated. `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.

For an organization with [email rules](/docs/email-rules), `email.received` events also carry `email.analysis.understanding`: the facts Primitive's classification recorded for the email (for example `doc_type` and `intent`, each with a confidence from 0 to 1), with a `status` of `ready`, `pending` or `unavailable`. The first delivery of an email is built without waiting for classification, so it usually says `pending`; read the facts later from `GET /v1/emails/{id}`. A delivery that a rule granted to an API key's webhook also names the rules that granted it in `email.analysis.understanding.rules`. See [The understanding block](/docs/email-rules#the-understanding-block).

## Field Reference

These descriptions are sourced from the webhook JSON schema used by the SDK, with the managed `email.analysis.sender` extension and the `email.analysis.understanding` block documented from the webhook delivery implementation. Required means required when its parent object or union variant is present.

### Top Level

FieldTypeRequiredDescription`id``string`yesUnique delivery event ID.`event``EmailEventType`yesEvent 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](#email-event-types).`version``WebhookVersion`yesAPI version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK.`delivery``object`yesMetadata about this webhook delivery.`email``object`yesThe email that triggered this event.`routing``object`noThe 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](#routing).

### Delivery

FieldTypeRequiredDescription`delivery.endpoint_id``string`yesID of the webhook endpoint receiving this event. Matches the endpoint ID from your Primitive dashboard.`delivery.attempt``integer`yesDelivery-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`yesISO 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](/docs/routing), 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_id``string | null`noThe endpoint the email was delivered to, or null when none resolved.`routing.matched_route_id``string | null`noThe recipient route that matched, or null when the outcome was not `matched`.`routing.matched_tier``"exact" | "wildcard" | "regex" | null`noThe match tier of the route that matched, or null when the outcome was not `matched`.`routing.default_scope``"domain" | "org" | null`noWhen the outcome was `defaulted`, whether the default came from the domain or the organization; null otherwise.`routing.version``integer`noDecision schema version, so you can detect shape changes.

### Email And SMTP

FieldTypeRequiredDescription`email.id``string`yesUnique email ID in Primitive. Use this ID when calling Primitive APIs to reference this email.`email.thread_id``string | null`noConversation 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.automated``boolean`noWhether the message was sent by a machine rather than a person: a bounce, an auto-reply, list or bulk mail, a report, or mail from the address it was delivered to. The same value `GET /v1/emails/{id}` returns, decided once when the email arrives. Use it to skip machine mail without fetching the email. Treat a missing value as unknown and fetch the email if you need it.`email.automated_reasons``string[]`noWhy `email.automated` is true, in rule order; empty when it is false. Current values: `null_envelope_sender`, `no_identifiable_sender`, `own_address`, `mailer_daemon`, `auto_submitted`, `precedence`, `list_unsubscribe`, `list_id`, `auto_response_suppress`, `failed_recipients`, `report`. Treat an unfamiliar value as a reason added after your code was written. Present whenever `email.automated` is.`email.received_at``string`yesISO 8601 timestamp (UTC) when Primitive received the email.`email.smtp``object`yesSMTP envelope information. This is the real sender and recipient info from the SMTP transaction, which may differ from headers.`email.smtp.helo``string | null`yesHELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction.`email.smtp.mail_from``string`yesSMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header.`email.smtp.rcpt_to``string[]`yesSMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery.`email.headers``object`yesParsed email headers. These are extracted from the email content, not the SMTP envelope.`email.headers.message_id``string | null`yesMessage-ID header value. Null if the email had no Message-ID header.`email.headers.subject``string | null`yesSubject header value. Null if the email had no Subject header.`email.headers.from``string`yesFrom header value. May include display name: `"John Doe" <john@example.com>``email.headers.to``string`yesTo header value. May include multiple addresses or display names.`email.headers.date``string | null`yesDate header value as it appeared in the email. Null if the email had no Date header.

### Raw Content

FieldTypeRequiredDescription`email.content``object`yesRaw email content and download information.`email.content.raw``RawContent`yesRaw email in RFC 5322 format. May be inline (base64) or download-only depending on size.`email.content.raw.included``boolean`yesWhether 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_bytes``integer`yesMaximum size in bytes for inline inclusion. Emails larger than this threshold require download.`email.content.raw.size_bytes``integer`yesActual size of the raw email in bytes.`email.content.raw.sha256``string`yesSHA-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 inlineBase64-encoded raw email (RFC 5322 format). Decode with `Buffer.from(data, 'base64')` in Node.js.`email.content.download``object`yesDownload information for the raw email. Always present, even if raw content is inline.`email.content.download.url``string`yesURL to download the raw email as-is in RFC 5322 format. Managed Primitive always issues HTTPS.`email.content.download.expires_at``string`yesISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 401.

### Parsed Content

FieldTypeRequiredDescription`email.parsed``ParsedData`yesParsed 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.error``ParsedError | null`yesAlways 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.message``string`when failedHuman-readable error message describing what went wrong.`email.parsed.error.retryable``boolean`when failedWhether retrying might succeed. If true, the error was transient. If false, the email itself is problematic.`email.parsed.body_text``string | null`yesPlain text body of the email. Null if the email had no text/plain part or parsing failed.`email.parsed.body_html``string | null`yesHTML body of the email. Null if the email had no text/html part or parsing failed.`email.parsed.reply_to``EmailAddress[] | null`yesParsed Reply-To header addresses. Null if the email had no Reply-To header or parsing failed.`email.parsed.cc``EmailAddress[] | null`yesParsed CC header addresses. Null if the email had no CC header or parsing failed.`email.parsed.bcc``EmailAddress[] | null`yesParsed BCC header addresses. Null if the email had no BCC header or parsing failed.`email.parsed.to_addresses``EmailAddress[] | null`yesParsed To header addresses. Null if the email had no To header or parsing failed.`email.parsed.reply_to[].address``string`yesThe email address portion. This is the raw value from the email header with no validation applied.`email.parsed.reply_to[].name``string | null`yesThe display name portion, if present. Null if the address had no display name.`email.parsed.cc[].address``string`yesThe email address portion.`email.parsed.cc[].name``string | null`yesThe display name portion, if present.`email.parsed.bcc[].address``string`yesThe email address portion.`email.parsed.bcc[].name``string | null`yesThe display name portion, if present.`email.parsed.to_addresses[].address``string`yesThe email address portion.`email.parsed.to_addresses[].name``string | null`yesThe display name portion, if present.`email.parsed.in_reply_to``string[] | null`yesIn-Reply-To header values (Message-IDs of the email or emails being replied to).`email.parsed.references``string[] | null`yesReferences header values (Message-IDs of the email thread).`email.parsed.attachments``WebhookAttachment[]`yesList of attachments with metadata. May contain partial attachment metadata even when parsing failed.`email.parsed.attachments[].filename``string | null`yesOriginal filename from the email. May be null if the attachment had no filename specified.`email.parsed.attachments[].content_type``string`yesMIME content type, for example `"application/pdf"` or `"image/png"`.`email.parsed.attachments[].size_bytes``integer`yesSize of the attachment in bytes.`email.parsed.attachments[].sha256``string`yesSHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download.`email.parsed.attachments[].part_index``integer`yesZero-based index of this part in the MIME structure.`email.parsed.attachments[].tar_path``string`yesPath to this attachment within the downloaded tar.gz archive.`email.parsed.attachments_download_url``string | null`yesURL to download all attachments as a tar.gz archive. Null if the email had no attachments or parsing failed.

### Auth

FieldTypeRequiredDescription`email.auth``EmailAuth`yesEmail authentication results for SPF, DKIM, and DMARC.`email.auth.spf``SpfResult`yesSPF verification result.`email.auth.dmarc``DmarcResult`yesDMARC verification result.`email.auth.dmarcPolicy``DmarcPolicy`yesDMARC policy from the sender's DNS record.`email.auth.dmarcFromDomain``string | null`yesThe organizational domain used for DMARC lookups.`email.auth.dmarcSpfAligned``boolean`yesWhether SPF aligned with the From domain for DMARC purposes.`email.auth.dmarcDkimAligned``boolean`yesWhether DKIM aligned with the From domain for DMARC purposes.`email.auth.dmarcSpfStrict``boolean | null`yesWhether DMARC SPF alignment mode is strict.`email.auth.dmarcDkimStrict``boolean | null`yesWhether DMARC DKIM alignment mode is strict.`email.auth.dkimSignatures``DkimSignature[]`yesAll DKIM signatures found in the email with their verification results.`email.auth.dkimSignatures[].domain``string`yesThe domain that signed this DKIM signature (d= tag). This may differ from the From domain.`email.auth.dkimSignatures[].selector``string | null`yesThe DKIM selector used to locate the public key (s= tag).`email.auth.dkimSignatures[].result``DkimResult`yesVerification result for this specific signature.`email.auth.dkimSignatures[].aligned``boolean`yesWhether this signature's domain aligns with the From domain for DMARC.`email.auth.dkimSignatures[].keyBits``integer | null`yesKey size in bits. Null if the key size could not be determined.`email.auth.dkimSignatures[].algo``string | null`yesSigning algorithm, for example `"rsa-sha256"` or `"ed25519-sha256"`.

### Analysis

FieldTypeRequiredDescription`email.analysis``EmailAnalysis`yesEmail analysis and classification results. Fields may be absent when that analysis was not performed.`email.analysis.spamassassin``object`noSpamAssassin analysis results. Present when spam scoring ran.`email.analysis.spamassassin.score``number`yesOverall spam score. Higher scores indicate higher likelihood of spam.`email.analysis.sender``object`noPrimitive managed sender authentication verdict derived from `email.auth`, or from a Primitive sender proof.`email.analysis.sender.authenticated``boolean`yesTrue when the From domain proved control of the send through aligned SPF, aligned DKIM, or provider attestation, or when the From address sent the message through Primitive.`email.analysis.sender.basis``"dmarc_aligned" | "google_provider_attested" | "primitive_sender" | "spf_aligned" | "unauthenticated"`yesThe public basis for the sender authentication verdict.`email.analysis.sender.reasons``string[]`yesShort factual reasons for the sender authentication verdict.`email.analysis.bounce``BounceAnalysis`noClassified delivery failure (DSN) details. Present on `email.bounced` events when the bounce parsed.`email.analysis.bounce.sent_email_id``string | null`yesThe id of your send this bounce belongs to (the same id as `GET /v1/sent-emails/{id}`), or `null` when it could not be linked. Linking is best effort: a bounce links only when it quotes the original Message-ID of a send from your organization and one of its failed recipients was on that send. Bounces of mail you sent through another provider are never linked.`email.analysis.bounce.failed_recipient``string | null`yesThe first failed recipient, as the bounce wrote it. The status and diagnostic fields describe this recipient.`email.analysis.bounce.failed_recipients``string[]`yesEvery address the bounce reports as failed, lowercased and deduplicated, up to 100. Includes both the final and the original recipient when an alias or forward rewrote the address. Empty when the bounce names no usable address.`email.analysis.tls_report``TlsReportAnalysis`noParsed SMTP TLS report (RFC 8460). Present on `email.tls_report` events when the report parsed.`email.analysis.dmarc_report``DmarcReportAnalysis`noParsed DMARC aggregate report (RFC 7489). Present on `email.dmarc_report` events when the report parsed.`email.analysis.forward``ForwardAnalysis`noForward detection and analysis results. In managed Primitive webhook deliveries, this object is included only when forwarded content is detected.`email.analysis.forward.detected``boolean`yesWhether 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[]`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_path``string`when attachmentPath to the attachment in the attachments tar archive.`email.analysis.forward.results[].attachment_filename``string | null`when attachmentOriginal filename of the attachment, if available.`email.analysis.forward.results[].analyzed``boolean`when attachmentWhether this attachment was analyzed.`email.analysis.forward.results[].original_sender``ForwardOriginalSender | null`yesOriginal sender of the forwarded email, if extractable.`email.analysis.forward.results[].original_sender.email``string`yesEmail address of the original sender.`email.analysis.forward.results[].original_sender.domain``string`yesDomain of the original sender.`email.analysis.forward.results[].verification``ForwardVerification | null`yesVerification result for the forwarded email. Null when an attachment forward was not analyzed.`email.analysis.forward.results[].verification.verdict``ForwardVerdict`yesOverall verdict on whether the forward is authentic.`email.analysis.forward.results[].verification.confidence``AuthConfidence`yesConfidence level for this verdict.`email.analysis.forward.results[].verification.dkim_verified``boolean`yesWhether a valid DKIM signature was found that verifies the original sender.`email.analysis.forward.results[].verification.dkim_domain``string | null`yesDomain of the DKIM signature that verified the forward, if any.`email.analysis.forward.results[].verification.dmarc_policy``DmarcPolicy`yesDMARC policy of the original sender's domain.`email.analysis.forward.results[].summary``string`yesHuman-readable summary of the forward analysis.`email.analysis.forward.attachments_found``integer`yesTotal number of .eml attachments found.`email.analysis.forward.attachments_analyzed``integer`yesNumber of .eml attachments that were analyzed.`email.analysis.forward.attachments_limit``integer | null`yesMaximum number of attachments that will be analyzed, or null if unlimited.`email.analysis.understanding``EmailUnderstanding`noThe classification facts recorded for the email. Present on `email.received` events for organizations with email rules. Only `version` and `status` are always present; other fields are omitted, never null. See [The understanding block](/docs/email-rules#the-understanding-block).`email.analysis.understanding.version``integer`yesShape version of this object.`email.analysis.understanding.status``string`yes`ready` (classified), `pending` (not classified yet, or a failed classification that may still be retried) or `unavailable` (it will not be classified). Treat an unfamiliar value as one added after your client was built.`email.analysis.understanding.classification_id``string`when readyOpaque classification id.`email.analysis.understanding.vocabulary``string`when readyVocabulary version, for example `v1`.`email.analysis.understanding.classified_at``string`when readyWhen the email was classified (ISO 8601).`email.analysis.understanding.facts``Record<string, UnderstandingFact>`when readyFacts keyed by name. A fact the classification did not produce is absent; ignore names you do not recognize. Vocabulary `v1` has `is_cold_outreach`, `intent`, `doc_type` and `sender_relationship` (see [the column vocabulary](/docs/email-rules#the-column-vocabulary)).`email.analysis.understanding.facts.*.value``boolean | number | string | string[]`yesThe fact's value. Its JSON type depends on the fact; never null.`email.analysis.understanding.facts.*.confidence``number`yesConfidence, from 0 to 1.`email.analysis.understanding.rules``UnderstandingRule[]`noPresent only on rule-granted deliveries to an API key's webhooks: the rules the receiving key lists by id that matched this email and granted this delivery, while each is `active`. A rule given a new version since it matched is still listed, with `version` the version that matched. A rule whose author has left the organization since writing it is not listed, even if they rejoined. A delivery through a rule share carries none. See [Keys and webhooks for a rule](/docs/email-rules#keys-and-webhooks-for-a-rule).`email.analysis.understanding.rules[].id``string`yesRule id, as the email rules API returns it.`email.analysis.understanding.rules[].version``integer`yesThe rule version that matched (the newest, if more than one matched the email).`email.analysis.understanding.rules[].name``string`yesThe rule's current name.`email.analysis.understanding.rules[].description``string`yesThe plain-English description the rule version was written from.`email.analysis.understanding.rules[].explanation``string`yesWhat the rule version matches, in plain English.`email.analysis.understanding.rules[].questions``UnderstandingRuleQuestion[]`yesThe rule's questions that were answered for this email. Empty when it has none.`email.analysis.understanding.rules[].questions[].question``string`yesThe question.`email.analysis.understanding.rules[].questions[].answer``boolean`yesThe answer; true for yes.`email.analysis.understanding.rules[].questions[].confidence``number`yesHow sure the answer is, from 0 to 1.

## Sent Email Events

Primitive can also tell you what happened to mail you send: through the API, from a Primitive Function, or from Google Workspace through a [mail relay](/docs/mail-relay).

### Opting in

These events are **opt-in**: an endpoint receives them only if its `rules.event_types` lists them by name. An endpoint that leaves `event_types` unset never receives them, so handlers written for the inbound payload are unaffected. An endpoint with a list receives exactly what it lists, so if you add `sent_email.*` names to an endpoint that also handles inbound mail, keep `email.received` (and any other inbound events you handle) in the list.

We recommend a dedicated endpoint for them: a handler written for `email.received` that receives a different payload may fail or reject it. Whatever the endpoint, route on the `event` field (also sent as the `X-Webhook-Event` header) and return 2xx for event types you do not handle.

Which endpoints receive them: http endpoints that list the event, are not route targets, are scoped to the sending domain if they are domain-scoped, and listed a `sent_email.*` event before the send was made. Turning on a subscription does not replay earlier sends. Pull endpoints and Function endpoints do not receive `sent_email.*` events, and an endpoint's sender and size rules do not apply to them.

### Events

EventWhen it fires`sent_email.accepted`Exactly once for every send Primitive accepted, whatever state the send has reached by the time the event is created. Its `sent_email.status` can already be `delivered`.`sent_email.delivered`A recipient's receiving server accepted the message.`sent_email.failed`A recipient permanently could not be delivered to: the receiving server rejected the message, or kept asking to retry until the retry period ran out.`sent_email.completed`Once per send, when every recipient has a final result, after that send's last `delivered` or `failed` event. Carries the totals.

Every recipient (To, Cc and Bcc) gets exactly one final result: one `delivered` or one `failed`, never both, never a second one. The one exception is a mail relay send to colleagues in your own Workspace: they are delivered by your mailbox provider, not through the relay, so Primitive has no result for them, sends no event for them, and lists them in `sent_email.completed`'s `not_relayed` (see [Not relayed](#not-relayed)). There is no event for a temporary deferral: the receiving server asked Primitive to retry, and only the final result produces an event. Retries continue for up to 5 days, so a result can arrive days after the send.

### Sends with no result events

- A send that ends `gate_denied` or `agent_failed` returned an error to your API call and produces no events.

- A scheduled send canceled before its send time produces no events.

- A send that ends `unknown` after `sent_email.accepted` produces no result events and no `sent_email.completed`.

- Results for a send can arrive later than usual when Primitive has to reconcile them.

### Example


```
{
  "id": "6f0f8a1e-5c2d-4b8e-9a7d-3c1e2f4a5b6c",
  "event": "sent_email.failed",
  "version": "2026-12-18",
  "created_at": "2026-12-18T10:00:04.000Z",
  "delivery": { "endpoint_id": "8c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "attempt": 1, "attempted_at": "2026-12-18T10:00:05.000Z" },
  "sent_email": {
    "id": "0b1c2d3e-4f50-4172-8394-a5b6c7d8e9f0",
    "status": "deferred",
    "status_changed_at": "2026-12-18T10:00:03.000Z",
    "created_at": "2026-12-18T10:00:00.000Z",
    "source": "api",
    "from_address": "agent@example.com",
    "to_addresses": ["alice@example.org"],
    "cc": ["bob@example.net"],
    "bcc": ["audit@example.com"],
    "recipient_count": 3,
    "subject": "Your quote",
    "message_id": "<abc123@example.com>",
    "thread_id": "5d1f7a3c-2b9e-4c6d-8a0f-1e2d3c4b5a69",
    "tags": [{ "name": "campaign", "value": "q4-renewals" }],
    "relay": null
  },
  "scope": "recipient",
  "recipient": { "address": "bob@example.net", "type": "cc" },
  "outcome": {
    "result": "failed",
    "failure_kind": "rejected",
    "smtp_response_code": 550,
    "smtp_enhanced_status_code": "5.1.1",
    "smtp_response_text": "550 5.1.1 <bob@example.net>: Recipient address rejected: User unknown",
    "at": "2026-12-18T10:00:04.000Z"
  }
}
```


The `sent_email.completed` for the same send, once Alice and the audit copy are delivered:


```
{
  "id": "2a7e9c4d-1b3f-4e5a-8c6d-7f8e9a0b1c2d",
  "event": "sent_email.completed",
  "version": "2026-12-18",
  "created_at": "2026-12-18T10:00:09.000Z",
  "delivery": { "endpoint_id": "8c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "attempt": 1, "attempted_at": "2026-12-18T10:01:02.000Z" },
  "sent_email": { "id": "0b1c2d3e-4f50-4172-8394-a5b6c7d8e9f0", "status": "delivered", "recipient_count": 3, "...": "the same fields as above" },
  "summary": {
    "recipient_count": 3,
    "delivered": 2,
    "failed": 1,
    "failed_by_kind": { "rejected": 1, "expired": 0 },
    "not_relayed": 0,
    "not_relayed_recipients": []
  }
}
```


### Top-level fields

- `id`: the event id, an opaque string. It is the same on every retry and at every endpoint, and equal to the `X-Webhook-Id` header.

- `event`: the event type, also sent as the `X-Webhook-Event` header.

- `version`: the version of the `sent_email.*` payload family, versioned separately from the inbound `email.*` payload. It is not an event date.

- `created_at`: when the event happened, as far as Primitive knows it: the result's time for `delivered` and `failed`, the last result's time for `completed`. For `sent_email.accepted` it is the acceptance time only if the event was created while the send was still in its accepted state; otherwise it is when the event was created, which can be later than a result for the same send.

- `delivery`: this webhook attempt, not the email's delivery: `endpoint_id`, `attempt` (1 for the first attempt) and `attempted_at`.

- `sent_email`: the send (below).

- On `delivered` and `failed`: `scope`, `recipient`, `outcome`, and on roll-ups `recipient_count`, `reason` and (failed roll-ups) `recipients`.

- On `completed`: `summary` (`recipient_count`, `delivered`, `failed`, `failed_by_kind`, `not_relayed`, `not_relayed_recipients`).

Keys that do not apply to an event are absent, not null: `sent_email.accepted` carries none of `scope`, `recipient`, `recipient_count`, `reason`, `outcome`, `recipients` or `summary`, and a `scope: "recipient"` event carries no `recipient_count`, `reason` or `recipients`.

### What sent_email is

The send's **current** state at the moment this delivery is attempted, with the same fields as [GET /v1/sent-emails/{id}](/docs/api). It is not a snapshot of when the event was created, so two attempts of the same event can carry different `sent_email` values, and it is not this event's result: use `outcome` and `recipient` for that. To keep a send's latest state, keep the `sent_email` from the attempt with the latest `delivery.attempted_at`.

- `id`: the id the send API returned. For mail relay sends there is no API call, and the id first appears in these events.

- `status`: the send's overall status as `GET /v1/sent-emails/{id}` reports it. It can disagree with any single recipient's result: it can read `delivered` while a recipient failed, or `deferred` while another recipient was already delivered. Never use it as a recipient's result, and never use it to decide when every recipient is done: use `sent_email.completed`. Values: `queued` (accepted by the API, not yet handed to the sender), `scheduled` (waiting for its send time), `submitted_to_agent` (handed to Primitive's outbound mail server; nothing to do with AI agents), `deferred` (a receiving server asked to retry later), `delivered`, `bounced`, `wait_timeout` (a send that waited for its result stopped waiting; delivery continues), `unknown` (Primitive could not confirm the result), `agent_failed`, `gate_denied`, `canceled`. Treat it as an open set.

- `recipient_count`: every recipient of the send, To, Cc and Bcc together, each address counted once: always the size of `to_addresses`, `cc` and `bcc` combined without duplicates. To know when every recipient is done, use `sent_email.completed`, whose `delivered + failed + not_relayed` equals it.

- `source`: how the mail entered Primitive, `api` or `mail_relay` (Function sends report `api`). It says nothing about who wrote it. Treat unknown values as newer additions.

- `relay`: null for API sends. For relay sends, `hostname` (the Primitive relay that took the message) and `via` (`mail_relay`).

- `cc` and `bcc` are null when the send had none. `tags` are the ones given on the send request, null or empty when there are none. Relay sends carry no tags; correlate them by `message_id` or `thread_id`.

- For the time a send was created, use `sent_email.created_at`. There is no separate acceptance time, and `status_changed_at` is not one.

### Scope: per recipient, or a roll-up

`scope: "recipient"` events name one recipient in `recipient` (`{address, type}`). This is how every recipient of every send is reported, up to 100 per send.

Sends with more than 100 recipients (only possible for mail relay sends) report the first 100, in the order To, then Cc, then Bcc, with their own events, and the rest in roll-ups: `scope: "message"`, `recipient: null`, `reason: "rollup"`, and `recipient_count`, the number of recipients that roll-up covers. Roll-ups have null `smtp_*` fields in `outcome`.

- Failed roll-ups are sent early. As failures among the rolled-up recipients become known, Primitive sends a `sent_email.failed` roll-up for each `failure_kind` with new failures. It lists them in `recipients`: one entry per failed recipient with `address`, `type`, `smtp_response_code`, `smtp_enhanced_status_code`, `smtp_response_text` and `at`, and its `recipient_count` is the length of that list. Each failed roll-up covers only the recipients it lists, a send can have several, and no recipient is ever listed twice.

- One delivered roll-up is sent once every rolled-up recipient has a final result. It carries the count of delivered rolled-up recipients and no addresses.

The first 100 recipients' own events, plus the failed roll-ups' lists, plus the delivered roll-up's count, add up to every recipient the relay sent to.

Sends recorded before Primitive kept each recipient's result can instead report one result for several recipients: `scope: "message"`, `reason: "legacy_message_result"`, with `recipient_count` saying how many recipients it covers. This applies only to those older sends.

### outcome

`outcome` is this event's result:

- `result`: `delivered` or `failed`, matching the event name.

- `failure_kind`: null when delivered; otherwise `rejected` or `expired` (below).

- `smtp_response_code`, `smtp_enhanced_status_code`, `smtp_response_text`: the receiving server's answer. On `expired` they hold its last temporary answer. Null on roll-ups.

- `at`: when the result was recorded.

### Failure kinds

- `rejected`: the receiving server refused the message permanently. This covers mailboxes that do not exist (enhanced status `5.1.x`) and policy or spam refusals (`5.7.x`). Decide suppression on `smtp_enhanced_status_code`, for example suppress on `5.1.x`, not on `rejected` alone.

- `expired`: the receiving server kept asking to retry until the 5-day retry period ran out. Do not suppress on it: the address may be fine.

The list is closed for this version.

### Recipient types

`to`, `cc` or `bcc`: the list that names the recipient. On a mail relay send, every recipient the message was sent to but that no To or Cc header names is `bcc` and is listed in `sent_email.bcc`, whether it was a Bcc recipient or, for example, a copy added by a routing rule: Gmail removes the Bcc header before relaying, so the two cannot be told apart. Addresses are lowercased, and a recipient named in more than one list is one recipient, typed by the first list that names it (To, then Cc, then Bcc).

### Not relayed

On a mail relay send, To and Cc can name colleagues in your own Workspace. Colleagues in the same Workspace are delivered by your mailbox provider, not through the relay, so Primitive has no result for them; they are listed in `not_relayed`. They get no per-recipient events and are never counted as delivered. Primitive knows exactly who the relay received (its SMTP envelope), so everyone else named in To or Cc is not relayed.

`sent_email.completed` is sent once every relayed recipient has a final result. Its `summary` carries:

- `not_relayed`: how many recipients were not relayed. Always `0` for sends made through the API.

- `not_relayed_recipients`: those recipients, each `{address, type}`. `[]` when there are none.

`summary.delivered + summary.failed + summary.not_relayed` always equals `summary.recipient_count`. For a relay send recorded before Primitive kept the relay's envelope, who was relayed cannot be known: `not_relayed` and `not_relayed_recipients` are `null`, and every recipient is treated as relayed.

### Delivered is final, and late bounces

`sent_email.delivered` means the recipient's server accepted the message, not that it reached the inbox. A recipient's first final result is never followed by another. If that server later sends a bounce message, it arrives as the inbound [email.bounced](#email-event-types) event, whose `email.analysis.bounce.sent_email_id` links it to the send.

### Duplicates, retries and timing

- The body `id` (equal to the `X-Webhook-Id` header) is the same on every retry and at every endpoint: store it and skip repeats. Never compare bodies to detect duplicates; `delivery` and `sent_email` differ between attempts. If several endpoints share one store, key on `id` alone to process each event once, or on `id` plus `delivery.endpoint_id` to process it once per endpoint.

- The first attempt is made within about two minutes of the result.

- If your endpoint does not return 2xx within 10 seconds, the event is retried with the same `id` and a higher `delivery.attempt`: about 5 minutes, 30 minutes, 2 hours and 8 hours after each failed attempt, so at most 5 attempts over about 11 hours. Size your dedupe store for at least a day.

- Order is best effort. Results for a send are normally held until its `sent_email.accepted` has been delivered, and its `sent_email.completed` until its results have, but not indefinitely, so an event can arrive before an earlier one. Treat `sent_email.accepted` as first for its `sent_email.id` and `sent_email.completed` as last, and order results by `created_at`.

### Signing

Sent email events are signed exactly like every other event: see [Signature Verification](/docs/signature-verification).

- The header is `Primitive-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.

- The key is the secret that signs the endpoint, used exactly as Primitive shows it: the whole string, including the `whsec_` prefix when it has one. Some secrets have no prefix; use those as shown too. That is your organization's webhook secret unless the endpoint has its own (its `signed_with` says which; see [Which secret](/docs/signature-verification#which-secret)). Endpoints without their own share the organization's secret, so also check that `delivery.endpoint_id` is the endpoint that received the request.

- `t` is recomputed on every attempt. Reject values more than 300 seconds old.

- Verify against the raw request bytes, before parsing.

Worked example: with the secret `whsec_example`, `t=1797588005` and the raw body `{"id":"9f1c3a52-7d4e-4b8a-9c21-5e6f7a8b9c0d","event":"sent_email.completed"}`, the signed string is `1797588005.` followed by that body, and the header is:


```
Primitive-Signature: t=1797588005,v1=4f93d0ceb7f6fdd4a71f4d6beb08a5624a753a376841fd4aa93c62ecaa42c9b2
```


An SDK version that does not know these events treats them as unknown events; check the event type before parsing the body as an inbound email.

### Safety and deletion

- Every event carries the full recipient list including Bcc, and failed roll-ups list addresses: never forward payloads to recipients.

- `smtp_response_text` is text written by a third-party server. It can contain addresses and its length is not fixed: size storage for several kilobytes. Treat it as untrusted and never render it as HTML.

- Deleting a sent email stops its events: pending ones are not delivered, and the stored copies of payloads already delivered are cleared.

## Rule Request Events

When a member asks another for an [email rule](/docs/email-rules) over their mail, each of them can be told through a webhook they own. Each event goes to the person who did not act.

EventSent toWhen`email_rule_request.created`the member askedA request was made to them.`email_rule_request.accepted`the member who askedIt was accepted, as asked or changed first (`edited_on_accept`).`email_rule_request.declined`the member who askedIt was declined.`email_rule_request.withdrawn`the member askedThe member who asked withdrew it before it was answered.

- They are opt-in: an endpoint receives them only if its `rules.event_types` lists them.

- They go only to an http webhook the person they are for made for themself and still owns, while that person is still a member: never to the organization's webhooks, a key's, a webhook a transfer moved, or anyone else's.

- A webhook is subscribed to them only when it is made, by `POST /v1/endpoints` with them in `rules.event_types`, so the URL they go to is one its owner chose; an existing webhook that receives none of the rule request or objection events cannot be given them. Only its owner, acting as themself, can change its URL, its events or its owner, and it cannot be given to a key; such a change by anyone else is refused with `403 forbidden` and `details.reason` `rule_request_events_need_own_webhook`. Deleting it works as for any webhook. A webhook made for them is its maker's own whatever their access, also for someone with access to every mailbox. Anything else it lists reaches it only for mail its maker can read, and it receives nothing once they leave the organization. It is a route target, never a domain or organization default: it receives mail only through a [route](/docs/routing), and `is_route_target: false` or `takeover` alongside these events is refused with `400`.

- The body carries `type`, not `event`: route on the `X-Webhook-Event` header or on `type`.

- The payload carries `id`, `type`, `occurred_at` and `email_rule_request`, with the request's `id`, `name`, `status`, `created_at`, `decided_at`, `message` and `decline_reason`. On `accepted` it also carries `share_id` and `edited_on_accept`. On `created` and `withdrawn`, a top-level `proposer` carries the asker's `user_id` and `name` (null when they have none). It does not carry the rule or anything read from mail: read the request with `GET /v1/email-rule-requests/{id}`.

- `name`, `message` and `decline_reason` are written by members: `message` (the asker's note) and `decline_reason` (the reason given for a decline) are each up to 500 characters, or null. Treat all three as untrusted and never render them as HTML.


```
{
  "id": "6f1c2d1e-8a5b-4c3d-9e2f-1a2b3c4d5e6f",
  "type": "email_rule_request.created",
  "occurred_at": "2026-11-11T09:30:00.000Z",
  "email_rule_request": {
    "id": "0b9a3c1e-6c4d-4a7e-9a51-000000000001",
    "name": "Vendor billing",
    "status": "pending",
    "created_at": "2026-11-11T09:30:00.000Z",
    "decided_at": null,
    "message": "For the vendor invoices only, please.",
    "decline_reason": null
  },
  "proposer": { "user_id": "2c4e6a8b-1d3f-4b5a-8c7e-000000000002", "name": "Sam Rivera" }
}
```


- A job sends them every few minutes, so one can arrive a few minutes after the change. They are not replayed.

## Rule Objection Events

When a member [objects to a rule over their own mail](/docs/email-rules#objecting-to-a-rule-over-your-mail), asking its author to turn it off, each of them can be told through a webhook they own. Each event goes to the person who did not act.

EventSent toWhen`email_rule_objection.created`the rule's authorA member objected to their rule.`email_rule_objection.accepted`the member who objectedThe author accepted it, turning the rule off.`email_rule_objection.declined`the member who objectedThe author declined it. The rule is as it was.`email_rule_objection.withdrawn`the rule's authorThe member who objected withdrew it before it was answered.

- They follow the same rules as the rule request events above: opt-in, only to an http webhook the person they are for made for themself and still owns, while they are still a member, subscribed only when the webhook is made, and never given to a key. A change to such a webhook by anyone else is refused with `403 forbidden` and `details.reason` `rule_request_events_need_own_webhook`, which covers both families.

- The body carries `type`, not `event`: route on the `X-Webhook-Event` header or on `type`.

- The payload carries `id`, `type`, `occurred_at` and `email_rule_objection`, with the objection's `id`, its `rule` (`id` and `name`: the name the rule had when the objection was made; `id` is null once the rule is deleted outright), `status`, `created_at`, `decided_at` and `decline_reason`. Times are ISO 8601 timestamps. On `created` and `withdrawn`, a top-level `objector` carries the objecting member's `user_id` and `name` (null when they have none).

- It never carries the objector's message: withdrawing an objection clears it, which a delivered copy could not undo. Read it with `GET /v1/email-rule-objections/{id}` until it is withdrawn. Nothing read from mail is carried either.

- `rule.name` and `decline_reason` are written by members (`decline_reason` is up to 500 characters, or null). Treat both as untrusted and never render them as HTML.


```
{
  "id": "7a2d3e4f-9b6c-4d5e-8f10-2b3c4d5e6f70",
  "type": "email_rule_objection.created",
  "occurred_at": "2026-11-12T09:30:00.123456+00:00",
  "email_rule_objection": {
    "id": "1c8b4d2f-7d5e-4b8f-8b62-000000000003",
    "rule": { "id": "3d5f7b9c-2e4a-4c6b-9d8f-000000000004", "name": "Vendor billing" },
    "status": "pending",
    "created_at": "2026-11-12T09:30:00.123456+00:00",
    "decided_at": null,
    "decline_reason": null
  },
  "objector": { "user_id": "4e6a8c0d-3f5b-4d7c-8e9a-000000000005", "name": "Alex Kim" }
}
```


- A job sends them every few minutes, so one can arrive a few minutes after the change. They are not replayed.

## Security

Every webhook includes a `Primitive-Signature` header. Verify it before trusting the payload. See [Signature Verification](/docs/signature-verification).

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
