Every email that arrives at your alias is transformed into structured JSON and delivered to your webhook endpoint. This reference covers every field you can expect, including the enrichment features available on each plan.
The example and the field tables below are generated from the schema the server actually emits, so they cannot drift from reality. Everything else on this page is written by hand.
Full payload example
A Business+ delivery with every optional block present. Lower plans and per-alias settings remove blocks — the tables below say which.
{
"message": {
"sender": {
"email": "alice@example.com",
"name": "Alice Martin"
},
"recipient": {
"email": "invoices+acme@yourdomain.com",
"name": null,
"tag": "acme"
},
"subject": "Invoice #2026-0042",
"content": {
"text": "Hi team,\n\nPlease find invoice #2026-0042 attached.\n\nBest,\nAlice",
"html": "<p>Hi team,</p><p>Please find invoice #2026-0042 attached.</p>",
"markdown": "Hi team,\n\nPlease find invoice #2026-0042 attached.",
"links": [
{
"url": "https://billing.example.com/inv/2026-0042",
"domain": "billing.example.com",
"anchorText": "View it online",
"source": "html"
}
]
},
"date": "2026-07-13T10:30:00.000Z",
"attachments": [
{
"filename": "invoice-2026-0042.pdf",
"contentType": "application/pdf",
"size": 245680,
"downloadUrl": "https://app.emailconnect.eu/attachments/9f8e7d6c5b4a3f2e/download",
"status": "completed",
"uploadType": "sync",
"virusScan": {
"status": "clean",
"duration": 42
}
}
]
},
"envelope": {
"messageId": "<abc123@example.com>",
"xMailer": null,
"xOriginalTo": "invoices+acme@yourdomain.com",
"returnPath": "alice@example.com",
"allRecipients": {
"to": [
"invoices+acme@yourdomain.com"
],
"cc": [],
"bcc": []
},
"headers": {
"from": "alice@example.com",
"subject": "Invoice #2026-0042"
},
"processed": {
"timestamp": "2026-07-13T10:30:01.000Z",
"domain": "yourdomain.com",
"alias": "invoices+acme@yourdomain.com",
"originalSize": 248320
}
},
"spam": {
"score": -0.99,
"engine": "rspamd",
"symbols": [
{
"name": "R_DKIM_ALLOW",
"weight": -0.2,
"description": "example.com:s=selector1"
}
],
"authentication": {
"dkim": {
"result": "pass",
"domain": "example.com"
},
"spf": {
"result": "pass",
"domain": "example.com"
},
"dmarc": {
"result": "pass",
"domain": "example.com"
}
}
},
"classification": {
"type": "normal",
"confidence": "definite",
"signals": []
},
"integrity": {
"contentHash": "a3f2b8c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"rawEmailHash": "9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e"
},
"security": {
"virusScan": {
"scanned": true,
"engine": "clamav",
"attachmentsScanned": 1,
"attachmentsSkipped": 0,
"threatsFound": 0
}
},
"domainId": "dom_a1b2c3",
"aliasId": "ali_d4e5f6"
}
Field reference
Plan is the capability gate. A (note) in the description says when a field may be absent — either because it is optional, or because a per-alias setting removes it before delivery.
routing
| Field | Type | Plan | Description |
|---|---|---|---|
aliasId |
string |
All plans | The ID of the alias that matched. Catch-all deliveries carry the catch-all alias's id — every domain's *@ catch-all is a real alias. null only for domain-level webhook deliveries with no alias involved (optional, nullable) |
domainId |
string |
All plans | The ID of the domain that received the email — correlate a delivery back to a resource in your account (optional, nullable) |
id |
string |
All plans | Identifier of this message on EmailConnect. Same value as the webhook-id header; accepted by the API wherever a message id is (optional) |
message
| Field | Type | Plan | Description |
|---|---|---|---|
message |
object |
All plans | — |
message.attachments |
array |
All plans | Attachments, capped at 20 per email (alias: allowAttachments) |
message.attachments[].content |
string |
All plans | Base64-encoded file content, for inline delivery. Present only for attachments small enough to inline (2 MB on paid plans, 128 KB on Free) (optional, alias: allowAttachments) |
message.attachments[].contentId |
string |
All plans | Content-ID of the MIME part, without angle brackets; null when the sender gave none. Present on every entry, whatever happened to the file. A body refers to a part as cid:. Having a Content-ID does not mean the body embeds the file: some clients give ordinary attachments one too (optional, nullable, alias: allowAttachments) |
message.attachments[].contentType |
string |
All plans | MIME type (alias: allowAttachments) |
message.attachments[].downloadUrl |
string |
All plans | Download URL for offloaded files. It points at EmailConnect, not at S3 — we redirect you to a short-lived signed URL at fetch time, so downloads can be counted, expired and kept in-region. It stays valid for as long as your retention window (optional, alias: allowAttachments) |
message.attachments[].excluded |
boolean |
All plans | true when the file was deliberately not delivered (optional, alias: allowAttachments) |
message.attachments[].excludeReason |
string |
All plans | Why the file was excluded. NOT a closed set — it is either a slug (virus-detected, too-large, too-many-attachments, disabled-in-alias-settings, system-domain-no-storage, data-residency-no-own-storage, inline-not-allowed, excluded, rejected, failed-to-process: ) or a human-readable sentence explaining the limit that was hit. Match on a prefix, not on equality. (optional, alias: allowAttachments) |
message.attachments[].filename |
string |
All plans | Original filename (nullable, alias: allowAttachments) |
message.attachments[].manageUrl |
string |
All plans | Link to the setting that caused this file to be excluded, when one did (optional, alias: allowAttachments) |
message.attachments[].metadata |
object |
Maker+ | Extracted file metadata (Maker+) (optional, alias: allowAttachments) |
message.attachments[].metadata.hash |
string |
Maker+ | SHA-256 of the file (optional, alias: allowAttachments) |
message.attachments[].metadata.height |
number |
Maker+ | Image height in pixels (optional, alias: allowAttachments) |
message.attachments[].metadata.pageCount |
number |
Maker+ | Page count, for PDFs (optional, alias: allowAttachments) |
message.attachments[].metadata.width |
number |
Maker+ | Image width in pixels (optional, alias: allowAttachments) |
message.attachments[].size |
number |
All plans | Size in bytes (alias: allowAttachments) |
message.attachments[].status |
included | pending | completed | failed | rejected | unknown |
All plans | included = inline in this payload · completed = uploaded, downloadUrl ready · pending = still uploading, retry shortly · failed / rejected / unknown = not available (optional, alias: allowAttachments) |
message.attachments[].storage |
inline | s3 | unknown |
All plans | Where the file ended up: inline in this payload, or offloaded to S3 (optional, alias: allowAttachments) |
message.attachments[].uploadType |
sync | async |
All plans | sync = uploaded before delivery, so downloadUrl works immediately · async = queued, so the file may briefly 202 while it finishes (optional, alias: allowAttachments) |
message.attachments[].virusScan |
object |
Business+ | Per-file scan result (Business+). Infected files are excluded from delivery and carry no downloadUrl (optional, alias: allowAttachments) |
message.attachments[].virusScan.duration |
number |
Business+ | Scan duration in milliseconds (optional, alias: allowAttachments) |
message.attachments[].virusScan.reason |
string |
Business+ | Why the file was skipped, when status is skipped (optional, alias: allowAttachments) |
message.attachments[].virusScan.status |
clean | infected | skipped | error |
Business+ | skipped = the file type is not scannable · error = the scan itself failed (alias: allowAttachments) |
message.attachments[].virusScan.threat |
string |
Business+ | Threat name when infected (e.g. Eicar-Test-Signature) (optional, alias: allowAttachments) |
message.content |
object |
All plans | — |
message.content.html |
string |
All plans | HTML body (nullable, alias: includeHtml) |
message.content.links |
array |
All plans | URLs extracted from the HTML and plain-text bodies (optional, alias: includeLinks) |
message.content.links[].anchorText |
string |
Maker+ | The clickable text of the link (Maker+ — Free plans receive url and domain only) (optional, nullable, alias: includeLinks) |
message.content.links[].domain |
string |
All plans | The URL's host, for quick filtering (alias: includeLinks) |
message.content.links[].source |
html | text |
Maker+ | Which body the link came from (Maker+) (optional, alias: includeLinks) |
message.content.links[].url |
string |
All plans | The extracted URL (alias: includeLinks) |
message.content.markdown |
string |
Maker+ | HTML body converted to clean Markdown, server-side. Useful for LLM pipelines and readable logging without an HTML parser (optional, nullable, alias: includeMarkdown (off by default)) |
message.content.reply |
object |
Maker+ | The new content of a reply, separated from the quoted history. Requires the includeReplyParsing alias setting, which is off by default (optional, alias: includeReplyParsing (off by default)) |
message.content.reply.text |
string |
Maker+ | The reply portion, with the quoted thread stripped (alias: includeReplyParsing (off by default)) |
message.content.text |
string |
All plans | Plain text body (nullable, alias: includeText) |
message.date |
string |
All plans | When the email was sent (ISO 8601) |
message.recipient |
object |
All plans | — |
message.recipient.email |
string |
All plans | The address exactly as it was written, including any +tag — so you can always reconstruct what the sender typed |
message.recipient.name |
string |
All plans | Recipient display name (nullable) |
message.recipient.tag |
string |
All plans | The +tag from a plus-addressed recipient (inbox+support@ → "support"), or null. A natural routing key: one alias can back many tags. Treat it as untrusted input — senders invent tags freely (optional, nullable) |
message.sender |
object |
All plans | — |
message.sender.email |
string |
All plans | Sender email address |
message.sender.name |
string |
All plans | Sender display name, or null if the address carried none (nullable) |
message.subject |
string |
All plans | Email subject line (nullable) |
envelope
| Field | Type | Plan | Description |
|---|---|---|---|
envelope |
object |
All plans | Technical envelope, for debugging and routing. Removed unless the alias enables "include envelope" — when off, only messageId remains (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients |
object |
All plans | The To:, Cc: and Bcc: header lines of the message, not the delivery envelope. An address that received the mail only as Bcc is not listed here; the address we accepted the mail for is message.recipient.email (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.bcc |
array |
All plans | Addresses on a Bcc: header line. Sending servers remove that line, so this is almost always empty (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.cc |
array |
All plans | Addresses on the Cc: header line (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.to |
array |
All plans | Addresses on the To: header line (alias: includeEnvelope (reduced to messageId when off)) |
envelope.headers |
record |
All plans | The message headers by lower-cased name, as our parser read them. Not the raw header text: structured headers (content-type, dkim-signature and others) arrive as JSON strings, address headers as the first address, a repeated header joined with a comma, and the List-* headers folded into one list entry. list-id, list-unsubscribe and list-post are also present under their own names with the header text as sent (alias: includeEnvelope (reduced to messageId when off)) |
envelope.messageId |
string |
All plans | RFC 5322 Message-ID, as the sender set it. Stable across redeliveries, but chosen by the sender, so it can repeat across different emails — deduplicate on the webhook-id header or the top-level id instead (alias: includeEnvelope (reduced to messageId when off)) |
envelope.processed |
object |
All plans | What EmailConnect did with it (alias: includeEnvelope (reduced to messageId when off)) |
envelope.processed.alias |
string |
All plans | The address we accepted the mail for, the same address as message.recipient.email. With a catch-all or a +tag that is not the alias as you configured it; aliasId identifies the alias that matched (alias: includeEnvelope (reduced to messageId when off)) |
envelope.processed.domain |
string |
All plans | The domain that received it (alias: includeEnvelope (reduced to messageId when off)) |
envelope.processed.originalSize |
number |
All plans | Raw message size in bytes (alias: includeEnvelope (reduced to messageId when off)) |
envelope.processed.timestamp |
string |
All plans | When EmailConnect processed the message (ISO 8601) (alias: includeEnvelope (reduced to messageId when off)) |
envelope.returnPath |
string |
All plans | Bounce address (the SMTP envelope sender) (nullable, alias: includeEnvelope (reduced to messageId when off)) |
envelope.xMailer |
string |
All plans | The sending client, when it identified itself (nullable, alias: includeEnvelope (reduced to messageId when off)) |
envelope.xOriginalTo |
string |
All plans | The X-Original-To header of the message, when it carries one; otherwise null. A header value, not the delivery envelope: the address we accepted the mail for is message.recipient.email (nullable, alias: includeEnvelope (reduced to messageId when off)) |
spam
| Field | Type | Plan | Description |
|---|---|---|---|
spam |
union |
All plans | Spam analysis (Maker+). On Free plans this is replaced by a truthy stub — { available: false, message } — so check for spam.score, NOT for the presence of spam (optional) |
spam.authentication |
object |
Maker+ | DKIM, SPF and DMARC results — the strongest signal of who really sent this. Absent when the results could not be read with certainty; treat an absent result as not passed, never as passed (optional) |
spam.authentication.dkim |
object |
Maker+ | Proves the message was not altered in transit (nullable) |
spam.authentication.dkim.domain |
string |
Maker+ | The signing domain: the d= of the DKIM signature. With several signatures, the first one reported. Absent when the message is unsigned (optional) |
spam.authentication.dkim.result |
string |
Maker+ | pass, fail, or none |
spam.authentication.dmarc |
object |
Maker+ | The domain owner's policy on SPF/DKIM failure (nullable) |
spam.authentication.dmarc.domain |
string |
Maker+ | The domain of the From header, whose DMARC policy was evaluated. Absent when there is no result for it (optional) |
spam.authentication.dmarc.result |
string |
Maker+ | pass, fail, or none |
spam.authentication.spf |
object |
Maker+ | Proves the sending server was authorised by the domain (nullable) |
spam.authentication.spf.domain |
string |
Maker+ | The domain SPF was checked for: the envelope sender's domain (Return-Path), or the sending server's HELO name when the envelope sender is empty. It can differ from the From domain (optional) |
spam.authentication.spf.result |
string |
Maker+ | pass, fail, softfail, or none |
spam.available |
false |
All plans | Spam analysis is not included on your plan |
spam.engine |
rspamd |
Maker+ | The scanner that produced the score (optional) |
spam.message |
string |
All plans | Which plan unlocks it |
spam.report |
string |
Maker+ | The raw rspamd report, for debugging (optional, nullable) |
spam.score |
number |
Maker+ | rspamd composite score. Negative is clean; 5+ is worth suspicion; 7+ is very likely spam. Calibrate to your own traffic rather than trusting those as absolutes. There is deliberately no isSpam verdict — the cut-off is your policy, not ours |
spam.symbols |
array |
Maker+ | The individual rules that fired, and what each contributed — this is how you explain a score (optional) |
spam.symbols[].description |
string |
Maker+ | Rule detail, when rspamd supplied any (nullable) |
spam.symbols[].name |
string |
Maker+ | rspamd rule name, e.g. DMARC_POLICY_ALLOW |
spam.symbols[].weight |
number |
Maker+ | What this rule contributed to the score (can be negative) |
classification
| Field | Type | Plan | Description |
|---|---|---|---|
classification |
object |
All plans | Routing hints derived from RFC headers — filter out bounces and auto-replies without writing your own heuristics (optional) |
classification.confidence |
definite | likely |
All plans | definite = an unambiguous header said so · likely = inferred from weaker signals |
classification.signals |
array |
All plans | The exact headers behind the verdict, so you can audit it rather than trust it |
classification.signals[].header |
string |
All plans | The RFC header that carried the signal, e.g. auto-submitted |
classification.signals[].value |
string |
All plans | That header's value |
classification.type |
bounce | auto-reply | mailing-list | read-receipt | normal |
All plans | What kind of message this is. Derived from RFC headers, not from content — so it is a fact about the message, not a guess. "normal" means none of the automated shapes matched |
integrity
| Field | Type | Plan | Description |
|---|---|---|---|
integrity |
object |
All plans | Content hashes (Maker+). Detect duplicate deliveries, or build an audit trail. NOTE: this is not the webhook signature — that is an HTTP header (see webhook signing) (optional) |
integrity.contentHash |
string |
Maker+ | SHA-256 of the processed content, as a bare hex digest (no "sha256:" prefix) |
integrity.rawEmailHash |
string |
Maker+ | SHA-256 of the raw message as it arrived, before any processing |
security
| Field | Type | Plan | Description |
|---|---|---|---|
security |
object |
All plans | Virus scan summary (Business+, and requires the virus-scanning setting to be on). Infected attachments are excluded from delivery with excludeReason: "virus-detected" and carry no downloadUrl (optional) |
security.virusScan |
union |
Business+ | Check scanned first: when it is false, this object carries ONLY a reason — no engine, no counts |
security.virusScan.attachmentsScanned |
number |
Business+ | How many files were scanned |
security.virusScan.attachmentsSkipped |
number |
Business+ | How many were skipped as not scannable |
security.virusScan.definitionsUpdated |
string |
Business+ | When the virus definitions were last updated (optional) |
security.virusScan.engine |
string |
Business+ | Scanning engine (currently clamav) |
security.virusScan.engineVersion |
string |
Business+ | Engine version (optional) |
security.virusScan.reason |
scanner-unavailable | disabled |
Business+ | Why it did not run |
security.virusScan.scanned |
true | false |
Business+ | The scan ran |
security.virusScan.threatsFound |
number |
Business+ | How many threats were found |
Plus addressing and recipient.tag
Anything after a + in the recipient's address is a tag. It rides along with the email and arrives in your payload as message.recipient.tag, so one alias and one endpoint can serve many purposes.
Mail sent to inbox+support@yourdomain.com is delivered to the webhook configured for your inbox@yourdomain.com alias — the tag doesn't need its own alias, and you don't need to create one per tag. The payload arrives as:
"recipient": {
"name": null,
"email": "inbox+support@yourdomain.com",
"tag": "support"
}
Note that recipient.email keeps the full address that was actually used, tag included — so you can always reconstruct exactly what the sender typed. The tag field is simply that same value, pre-parsed for you.
A few details worth knowing:
- Untagged mail sets
tagtonull, never an empty string or a missing key. You can rely on the field being present. - Only the first
+separates.inbox+a+b@yourdomain.comyields the taga+b. - A trailing
+carries no tag.inbox+@yourdomain.comyieldsnull, since there's nothing after the delimiter. - Tags are free-form. You don't register them anywhere. Any tag a sender invents reaches your endpoint, so treat
tagas untrusted input and match it against values you expect rather than acting on it blindly.
This makes tag a natural routing key. A single inbox@ alias can back inbox+support@, inbox+billing@, and inbox+sales@, with your endpoint switching on recipient.tag instead of you maintaining three aliases and three webhooks.
Reading the spam score
We report the score and let you judge. There's no isSpam verdict, no threshold, and no action field — deliberately. A spam verdict is a policy decision that depends on your tolerance, not ours, and a boolean derived from a threshold you chose tells you nothing the score didn't already.
As a rough calibration: scores below 0 are clean, 5 and above is worth treating with suspicion, and 7+ is very likely spam. Tune to your own traffic rather than trusting these as absolutes.
Compare spam.score against your own cut-off, or express the cut-off declaratively with alias rules and let EmailConnect accept, block, or tag the mail before it ever reaches your endpoint.
On Free plans the spam object is replaced by a stub — { "available": false, "message": "…" } — rather than removed. Check for spam.score, not for the presence of spam.
How attachments reach you
Small files arrive inline, base64-encoded in message.attachments[].content. Larger ones are offloaded and arrive as a downloadUrl.
That URL points at EmailConnect, not at S3. We redirect you to a short-lived signed URL when you fetch it. That's deliberate: it means downloads can be counted, revoked, and kept in-region, and it means the link in your payload doesn't rot the moment a signature expires. Its real lifetime is your retention window — a stored file expires together with its email. After that the URL answers 410, and 404 once the email has been purged. Fetch the file and keep your own copy rather than passing the URL on: it is rate-limited (100 requests per 15 minutes per file and IP address) and the signed URL behind it is valid for one hour.
- On paid plans, files up to 2 MB are inlined; larger ones are offloaded to S3 — yours, if you've configured a storage connection, otherwise ours. So is any file we can't sensibly inline (a
.zip, a video), whatever its size — a 40 KB archive goes to S3. - On Free, there is no offloading. Files up to 128 KB are inlined and anything larger is rejected, arriving as
excluded: truewith a reason. S3 offloading starts at Maker. - The whole email, including attachments, must fit within 10 MB. Bigger messages are rejected, so there's no "use S3 for files over 10 MB" path.
- A maximum of 20 attachments per email are delivered. Beyond that, the rest are dropped.
Files uploaded synchronously (uploadType: "sync") have a downloadUrl that works immediately. Asynchronous uploads (uploadType: "async") may briefly return 202 with a retryAfter while the upload finishes. See Attachment processing for the full picture.
Identifying an email
Every delivery has an identifier that we assign: msg_…. It arrives twice, as the webhook-id header (on every delivery, signed or not) and as the top-level id in the body. It is the same on every retry and replay of an email, so it is the value to deduplicate on, and the API accepts it wherever it asks for a message id.
envelope.messageId is a different thing: the Message-ID header the sender's mail system wrote. It is useful for threading, but the sender chooses it, so it can be missing or repeated.
The body id is absent in two cases: when a custom payload template builds the body and does not name {{id}}, and on a replay of an email that was received before the field existed (October 2026). The header is sent in both.
Every attachment entry also carries contentId, null when the sender gave none — including for files that were stored or excluded.
Verifying what you received
integrity.contentHash and integrity.rawEmailHash (Maker+) are SHA-256 digests — of the processed content, and of the raw message as it arrived. Use them to compare content or build an audit trail. To detect a repeated delivery of the same email, use webhook-id (see above).
These are not the webhook signature. The signature that proves the request came from us is an HTTP header, not a payload field — see webhook configuration.
Features by plan
| Feature | Free | Maker | Business | Platform |
|---|---|---|---|---|
| Message basics (sender, recipient, subject, date) | + | + | + | + |
| Text + HTML body | + | + | + | + |
| Attachments (inline base64) | + | + | + | + |
Link extraction (url, domain) |
+ | + | + | + |
| Email classification (type, confidence, signals) | + | + | + | + |
| Link anchor text | - | + | + | + |
| Markdown conversion | - | + | + | + |
| Integrity hashes | - | + | + | + |
| Spam analysis (rspamd) | - | + | + | + |
| Attachment metadata (hash, dimensions, page count) | - | + | + | + |
| S3 attachment offloading | - | + | + | + |
| Virus scanning (ClamAV) | - | - | + | + |
| Custom payload builder | - | - | + | + |
Markdown and reply parsing are also per-alias settings, and both are off by default — a Maker plan alone won't produce them until you enable them on the alias.
Related topics
- Email processing — Full processing pipeline overview
- Attachment processing — Inline vs S3 delivery, file type support
- Webhook configuration — Set up and manage webhook endpoints