---
title: "Email received (per recipient; stable eventId for deduplication)"
method: POST
path: "inbound.received"
---

# Email received (per recipient; stable eventId for deduplication)

`POST inbound.received` (webhook)

## Headers

- `x-oveyon-event` string, required
- `x-oveyon-timestamp` integer, required
- `x-oveyon-signature` string, required

## Payload

- object
  - `event` 'inbound.received'
  - `eventId` string, uuid
  - `schemaVersion` integer
  - `timestamp` string, date-time
  - `message` InboundMessage
    - `id` string, uuid
    - `receivedAt` string, date-time
    - `from` string, nullable: Envelope sender (MAIL FROM); `""` is a bounce (null sender).
    - `fromHeader` string, nullable
    - `fromName` string, nullable
    - `subject` string, nullable
    - `domain` string, nullable
    - `thread` InboundThreadRef, nullable: The conversation this message belongs to (grouped by In-Reply-To/References within your account, including replies you sent through OVEYON). null = not resolved (fail-open) or older than the backfill.
      - `id` string, uuid, required: Thread id — GET /v1/inbound/threads/{id}; filter the list with ?thread=.
    - `recipients` object[]: One item per mailbox that received the message. Each recipient is judged by the policies and the security quarantine of ITS mailbox: one copy can be delivered while another is held.
      - `id` string
      - `to` string
      - `outcome` string: This copy: `accepted` (delivered) or `quarantined` (held for review). Messages from before per-mailbox policies carry the message outcome here.
      - `heldBy` 'agent_safety' | 'policy' | 'null', nullable: Who held this copy (kept after release, as history).
      - `policy` InboundPolicyInfo, nullable: The inbound policy outcome for this message (F4): the effective action, the name of the policy that closed the chain (or the first one that matched), and the accumulated tags. `would_<action>:<name>` tags (e.g. `would_hold:…`, `would_restrict_channels:…`) mean a policy WOULD have held/muted/restricted it, but it did not apply — see the decision feed for why; an agent should treat them as the rule's intent. null = no policy matched.
        - `action` 'hold' | 'mute' | 'tag' | 'restrict_channels' | 'pass' | 'notify'
        - `name` string, nullable
        - `tags` string[]
      - `spamFiltered` boolean: This copy's mailbox had the spam filter ON when it arrived: the message `spamFilter` applies to it (and its policies judged `spam_filter.score`). false = this mailbox did not ask; a score, if any, belongs to another mailbox of the same message.
    - `authentication` Authentication: null = not evaluated.
      - `spf` string, nullable
      - `dkim` string, nullable
      - `dmarc` string, nullable
    - `spamScore` integer, nullable
    - `spamFilter` InboundSpamFilter, nullable: The spam filter of the MAILBOX (rspamd at our receiving edge) — OPT-IN per mailbox: null when no mailbox of this message switched it on (or the message predates the filter), which is «no opinion», never «clean». It only annotates: we never refuse, hold or discard because of it; what to do with the score is your inbound policy (`spam_filter.score`). Different from `spamScore` (our own 0–100 heuristic, computed for every message).
      - `status` 'scanned' | 'unavailable' | 'skipped', required: `scanned` = there is a score; `unavailable` = the mailbox asked and the filter did not answer in time (delivered WITHOUT a score — fail-open); `skipped` = not scanned on purpose (`reason`).
      - `score` number, nullable, required: The rspamd score (decimal; negative = looks legitimate). Common thresholds: 6 (probable spam), 15 (spam). null unless `scanned`.
      - `action` 'no action' | 'greylist' | 'add header' | 'rewrite subject' | 'soft reject' | 'reject' | 'null', nullable, required: What rspamd SUGGESTS. Informational: we never execute it.
      - `reason` 'too_large' | 'bypass' | 'timeout' | 'error' | 'null', nullable, required: `skipped`: too_large (over 10 MiB) or bypass (mail from one of our own domains, on an explicit list, DKIM-signed by that same domain and passing DMARC — subdomains are not on it); `unavailable`: timeout or error.
      - `symbols` object[]: The rules that weighed, heaviest first (detail and webhook; omitted from the paginated list).
        - `name` string
        - `score` number
    - `agentSafety` AgentSafety, nullable
      - `verdict` 'clean' | 'suspicious' | 'dangerous': clean | suspicious | dangerous. Treat `null` (field absent) as «not evaluated», never as safe.
      - `score` integer: 0 = nothing found, 100 = maximum risk. The thresholds are 25 (suspicious) and 60 (dangerous).
      - `coverage` AgentSafetyCoverage, nullable: What the verdict actually looked at. null = message from before coverage was recorded. Read it before trusting `clean`.
        - `bodySampled` boolean, required: true when the scanner saw a head+tail sample (128 KiB) instead of the whole text/HTML, or a textual attachment was cut at its cap. A payload planted in the unsampled middle is not seen. Also true when an absurd header (a field over 64 KiB, a header block over 512 KiB, or over 5000 lines — 1000 in a MIME part) was cut before reading, or the message has more than 1000 MIME parts (the first 1000 are read).
        - `sanitized` boolean, required: true when hidden characters (Unicode Tags, zero-width, bidi overrides) were stripped from the text we hand you — the body, the subject or the sender display name (`fromName`). The raw copy (/raw) keeps them, for forensics.
        - `unscannedAttachments` integer, required: Attachments whose content the scanner did not read because they are not text: PDFs, images, spreadsheets, archives. Extract them yourself before a model reads them. Attached messages (.eml) ARE decoded and scanned.
      - `signals` object[]: Why the score is what it is. Present in the webhook payload and in GET /v1/inbound/{uid}; omitted from the paginated list. Each entry names one finding.
        - `type` string: Machine name of the finding, e.g. unicode_tags, imperativo_oculto, bidi, homoglifo. texto_ilegivel is a passage that cannot be normalized safely — too long without a single ASCII character (a very long paragraph of unbroken Chinese or Japanese, for example) or with too many combining marks — and is read in pieces; on its own, it leads at most to `suspicious`.
        - `points` integer: How much this finding added to the score.
        - `detail` string: Human-readable evidence — including the hidden text, decoded, so you can see what your agent would have read.
    - `sizeBytes` integer
    - `attachmentCount` integer
    - `expiresAt` string, date-time, nullable
    - `outcome` string: `accepted`, `blocked`, `quarantined` (every copy held) or `partial` (some copies delivered, some held — see `recipients[].outcome`) — branch on this, never on blockedReason. `policy` at message level is the first recipient's.
    - `blockedReason` string, nullable
    - `quarantine` InboundQuarantineInfo, nullable: Present ONLY on a message that was held by the agent-safety verdict (or, later, a reception policy) and then released by a human — panel or POST /v1/inbound/{uid}/release. Absent (null) on a normal delivery. Release is not absolution: agentSafety travels with it.
      - `reason` 'agent_safety_dangerous' | 'policy', required
      - `heldAt` string, date-time, required
      - `releasedAt` string, date-time, required
      - `releasedBy` string, nullable: `portal:<user>` or `api:<key prefix>`.
      - `agentSafety` object, nullable
        - `verdict` string
        - `score` integer
      - `policy` object, nullable: When one of your inbound policies held it (reason `policy`): its name and action.
        - `name` string
        - `action` string, nullable
    - `policy` InboundPolicyInfo, nullable: The inbound policy outcome for this message (F4): the effective action, the name of the policy that closed the chain (or the first one that matched), and the accumulated tags. `would_<action>:<name>` tags (e.g. `would_hold:…`, `would_restrict_channels:…`) mean a policy WOULD have held/muted/restricted it, but it did not apply — see the decision feed for why; an agent should treat them as the rule's intent. null = no policy matched.
      - `action` 'hold' | 'mute' | 'tag' | 'restrict_channels' | 'pass' | 'notify'
      - `name` string, nullable
      - `tags` string[]
  - `recipient` object
    - `id` string
    - `to` string
  - `attachments` object[]: The manifest, in ALL modes (summary included) — see InboundAttachment. In mode `full+attachments`, and only when the whole POST fits the cap, each item also carries `content`.
    - `ord` integer: 1-based, stamped when the message came in; stable forever.
    - `filename` string, nullable: Display only — it came from the sender (may repeat, be empty or carry a path).
    - `contentType` string: The type the SENDER declared (the Content-Type of the MIME part), as is. `application/octet-stream` when the part declared none.
    - `detectedType` string, nullable: The type read from the first bytes of the file itself (its signature), independent of the label. Recognized families: PDF; legacy Office, Outlook .msg and .msi installers (`application/x-ole-storage` — the container is the same); .docx/.xlsx/.pptx, OpenDocument and EPUB; zip, 7z, rar, gzip, bzip2, jar; PNG, JPEG, GIF, TIFF, BMP, WebP, HEIC, AVIF, JPEG XL; MP4, MOV, 3GP, AVI; MP3, OGG, WAV, M4A; RTF, PostScript, XML; Windows (.exe/.dll) and Linux (ELF) executables. `null` = no recognized signature (plain text, a rare format) — it does not mean the label is wrong, only that nothing was recognized. Also `null` for messages received before 2026-10-15.
    - `typeMismatch` boolean: true when `contentType` and `detectedType` both exist and belong to different families — image, audio/video, PDF, PostScript, office document (legacy and modern Office, OpenDocument, RTF), archive, executable, text (e.g. a `.doc` labelled `image/png`). Synonyms of the same type (`image/jpg`, `application/x-zip-compressed`) and the same container (`application/zip` for a .docx) do not count; a generic label (`application/octet-stream`) never mismatches. A label outside these families only mismatches when the content is an executable — an executable mismatches every label that does not declare one. Do not discard an attachment on the label alone — prefer `detectedType` when it exists.
    - `sizeBytes` integer: Measured on decode, not declared.
    - `sha256` string, nullable: Hex digest of the decoded bytes.
    - `url` string: Where to fetch the bytes (GET, scope read:inbound).
    - `content` string: Base64 bytes — only in mode `full+attachments` when `truncation.attachmentsInline` is true (all or nothing).
  - `truncation` object
    - `degraded` boolean
    - `requestedMode` string
    - `mode` string
    - `reason` string, nullable
    - `maxBytes` integer
  - `text` string, nullable: Full text body (modes full / full+attachments), capped per field.
  - `html` string, nullable: Third-party HTML — sanitize before rendering.
  - `replyText` string, nullable: The NEW text of the message with quoted history, Outlook/forwarded headers and the RFC 3676 signature removed (pt/en). Feed this to a model, not `text`. Equals `text` when nothing was quoted.
  - `replyStripped` boolean: True when something was removed to produce replyText.
  - `replyMarkers` string[]: What was recognized and removed.

## Acknowledgement `200`

Answer 2xx fast; do the heavy work afterwards.

---

[API](https://skmtc.dev/oveyon/apis/oveyon-api.md) · [All operations](https://skmtc.dev/oveyon/apis/oveyon-api/llms.txt) · [OpenAPI document](https://skmtc.dev/oveyon/apis/oveyon-api/revisions/abf64c09d297?raw)
