What's in your webhook payload
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@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.
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 | CID for inline images referenced from the HTML body ( |
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, disabled-in-alias-settings, system-domain-no-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) |
message.content.links[].anchorText |
string |
Maker+ | The clickable text of the link (Maker+ — Free plans receive url and domain only) (optional, nullable) |
message.content.links[].domain |
string |
All plans | The URL's host, for quick filtering |
message.content.links[].source |
html | text |
Maker+ | Which body the link came from (Maker+) (optional) |
message.content.links[].url |
string |
All plans | The extracted URL |
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 | Every recipient on the message, not just the alias that matched (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.bcc |
array |
All plans | All Bcc: addresses visible to us (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.cc |
array |
All plans | All Cc: addresses (alias: includeEnvelope (reduced to messageId when off)) |
envelope.allRecipients.to |
array |
All plans | All To: addresses (alias: includeEnvelope (reduced to messageId when off)) |
envelope.headers |
record |
All plans | The raw email headers, lower-cased (alias: includeEnvelope (reduced to messageId when off)) |
envelope.messageId |
string |
All plans | RFC 5322 Message-ID. Stable across redeliveries — use it to deduplicate (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 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 address the message was originally sent to, before any forwarding (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 (optional) |
spam.authentication.dkim |
object |
Maker+ | Proves the message was not altered in transit (nullable) |
spam.authentication.dkim.domain |
string |
Maker+ | The signing domain (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 policy domain (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 envelope domain checked (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) |
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) |
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 — once the email is purged, the file is gone and the URL returns 404.
- 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.
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 detect duplicate deliveries or build an audit trail.
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