Webhook events
The requests MailKite makes to you. Inbound mail arrives on your
domain's inbound webhook as email.received;
engagement on your outbound mail arrives on a separate, opt-in
tracking webhook as email.sent /
email.bounced / email.complained /
email.opened / email.clicked. Every delivery is
signed the same way — see
Verifying signatures before you trust
one, and Receiving email for inbound handlers.
Inbound: email.received
When mail arrives at an address on a verified domain we POST
this body to your webhook — the same body, byte for byte, on every
automatic retry and manual replay.
POST
your webhook URL email.received — a parsed inbound message.
Event body
email-received-event.jsonThe JSON body MailKite POSTs to your webhook when mail arrives at an address on a verified domain. The same body is re-sent verbatim on an automatic retry or a manual replay, so every field describes the original message — never the moment of the POST. Verify the x-mailkite-signature header over the raw body before trusting it. EVERY FIELD BELOW IS ALWAYS PRESENT. A value we do not have is null; nothing is ever omitted, at any nesting level. Write one rule for absence and it will hold for the whole payload. null means "we do not know" and is never a stand-in for a value we verified — in particular a null auth verdict is NOT a pass. See docs/api/models/email-received-event.md.
| Field | Type | Notes |
|---|---|---|
| id req | string | Stable message id: the literal prefix `msg_` followed by a version-4 UUID with its hyphens removed (32 lowercase hex characters, 36 in total). Identical across retries and replays — use it as your idempotency key. To store it as a native UUID, drop the `msg_` prefix and re-insert hyphens at 8-4-4-4-12; the transform is lossless in both directions. Every MailKite id is built this way and differs only in the prefix (`rte_`, `dom_`, `usr_`, …). |
| type req | "email.received" | The event type. Always email.received for inbound mail. |
| from req | object | The envelope sender. |
| from.address req | string | Sender email address, taken from the SMTP envelope (MAIL FROM) — the address we actually routed on. |
| from.name req | string · null | Sender display name, decoded from the MIME From: header. null when the message carried no display name, or when the From: header names a different address than the envelope did — as happens with mailing lists, forwarders, and spoofed mail. It is sender-asserted and unverified, like the Date: header: never use it for authorization, and check the auth block before showing it as an identity. |
| to req | object[] | The recipient address this delivery is for. One entry per event: a message addressed to several of your addresses is routed and delivered once per matching address. |
| to[].address req | string | Recipient email address, taken from the SMTP envelope (RCPT TO). |
| to[].name req | string · null | Recipient display name, decoded from the MIME To: header. null when absent, or when the To: header does not name this exact address (e.g. mail delivered via Bcc or an alias). |
| subject req | string · null | Decoded subject line. null when the message had no Subject header. |
| text req | string · null | Plain-text body, already MIME-decoded. null when the message had no text/plain part. |
| html req | string · null | HTML body, already MIME-decoded. null when the message had no text/html part. |
| textFromHtml req | string · null | Plain text WE DERIVED from `html` — not something the sender wrote. Populated only when `text` is null and `html` is not, so it never duplicates a body already present and the consumer's rule is one line: `text ?? textFromHtml`. null whenever `text` is present, or when there was no `html` to convert. About 20% of real inbound mail carries no text/plain part; this is that gap filled without putting words in the sender's mouth — `text` and `html` remain exactly what arrived. |
| threadId req | string · null | The conversation root: the message's In-Reply-To/References root, falling back to its own Message-ID. Pass it back as send()'s inReplyTo to reply in-thread. null when the message carried no usable id. |
| receivedAt req | integer | When MailKite accepted the message, in Unix epoch milliseconds (UTC). This is arrival time, not delivery time — it is read from the stored message, so a retry hours later or a replay months later reports the same instant. Distinct from the sender-supplied Date: header, which is self-asserted and spoofable. |
| receivedAtIso req | string (ISO 8601) | The same instant as receivedAt, rendered as an RFC 3339 / ISO 8601 UTC timestamp. |
| auth req | object | Authentication and spam verdicts. SPF and DKIM are computed at our receiving edge, where the connecting IP and SMTP envelope are still visible; DMARC is derived from them in the Worker (see docs/api/lib/dmarc.md). Every field is ALWAYS PRESENT and is null when that check did not run or produced no verdict — treat null as 'unknown', NEVER as 'pass'. SPF/DKIM values are passed through from the edge rather than re-mapped, so match the ones you care about and treat anything else as unknown. The spam verdict is INFORMATIONAL: we deliver flagged mail to you exactly like any other message and never drop, defer, or withhold a webhook because of it — you have context we don't, so you set the threshold. Use spamSignals to decide what to act on. |
| auth.spf req | string · null | SPF result for the sending IP. Typically pass, fail, softfail, or neutral. |
| auth.dkim req | string · null | DKIM signature result. Typically pass or fail. |
| auth.dmarc req | string · null | DMARC evaluation result for the message, derived in the Worker from the SPF and DKIM results plus identifier alignment against the From: header domain (RFC 7489 §4.2). One of: 'pass' (SPF or DKIM passed AND its domain aligned), 'fail' (the From domain publishes a DMARC record and neither identifier aligned and passed), 'none' (the From domain publishes no DMARC record — nothing was asserted, so nothing failed), 'temperror' (the policy lookup itself failed and the answer is unknown). null when evaluation could not run at all. 'none' and 'fail' are NOT the same thing: only 'fail' means a published policy was violated. |
| auth.spam req | string · null | Spam verdict: 'spam', 'ham', or null when the message was not scored. NEVER affects delivery — this message reached you because we do not act on this field. Edges running rspamd may pass through its action name instead. |
| auth.spamScore req | number · null | How strongly the message scored as spam, from 0 (clean) to 1. null when not scored. The threshold behind the `spam` verdict is 0.5, but you are free to pick your own — that is the point of publishing the number. |
| auth.spamSignals req | string[] · null | Which signals fired, and therefore WHY the score is what it is. Stable identifiers you can branch on: dnsbl_listed (the connecting IP is on a blocklist), dbl_listed (the From domain is on a domain blocklist), spf_fail, spf_softfail, dkim_fail, dmarc_fail, unaligned_from (the visible From: domain differs from the envelope and nothing authenticated either), no_auth (no SPF, DKIM or DMARC at all). Empty when nothing fired; null when the message was not scored. |
| attachments req | any[] | Decoded attachments. Empty array when the message had none. Every key below is always present on every entry. `url` and `content` are the one mutually exclusive pair — exactly one of them is non-null: normally `url` (a signed, credential-free GET link valid for 7 days), and `content` (base64 bytes inlined) on zero-retention and at-rest-encrypted domains where no object is stored. Handle both. |
| attachments[].id req | string · null | Attachment id, `<messageId>:<index>`. null when the bytes are inlined in `content` (nothing was stored to reference). |
| attachments[].filename req | string · null | Filename as sent, decoded. null when the part had no name. |
| attachments[].contentType req | string · null | MIME type as declared by the sender. null when absent. |
| attachments[].size req | integer | Decoded size in bytes. |
| attachments[].contentId req | string · null | The part's Content-ID, unbracketed. Pair it with `disposition: "inline"` to map an inline image back to the HTML body's `src="cid:<contentId>"`. null when the part declared none. |
| attachments[].disposition req | string · null | The part's Content-Disposition: 'attachment' or 'inline'. null when the sender declared none. |
| attachments[].url req | string · null | Signed, time-limited GET link — fetch it with no credentials. Valid for 7 days, after which the object is deleted (410 Gone). null when `content` carries the bytes instead. |
| attachments[].content req | string (base64) · null | The file bytes, base64-encoded, inlined because nothing was stored. Non-null only on zero-retention or at-rest-encrypted domains, in place of `url`. |
A full event, exactly as it's POSTed to your webhook — verify x-mailkite-signature over the raw body before trusting it.
{
"id": "msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91",
"type": "email.received",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai", "name": "Support" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"textFromHtml": null,
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": {
"spf": "pass", "dkim": "pass", "dmarc": "pass",
"spam": "ham", "spamScore": 0.05, "spamSignals": []
},
"attachments": [
{
"id": "msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"contentId": null,
"disposition": "attachment",
"url": "https://api.mailkite.dev/att/4f3c1a9e2b7d48e1a05c6f8b3d2e7a91/0?exp=…&sig=…",
"content": null
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/email-received-event.json",
"title": "email.received webhook event",
"description": "The JSON body MailKite POSTs to your webhook when mail arrives at an address on a verified domain. The same body is re-sent verbatim on an automatic retry or a manual replay, so every field describes the original message — never the moment of the POST. Verify the x-mailkite-signature header over the raw body before trusting it.\n\nEVERY FIELD BELOW IS ALWAYS PRESENT. A value we do not have is null; nothing is ever omitted, at any nesting level. Write one rule for absence and it will hold for the whole payload. null means \"we do not know\" and is never a stand-in for a value we verified — in particular a null auth verdict is NOT a pass. See docs/api/models/email-received-event.md.",
"type": "object",
"required": [
"id",
"type",
"from",
"to",
"subject",
"text",
"html",
"textFromHtml",
"threadId",
"receivedAt",
"receivedAtIso",
"auth",
"attachments"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Stable message id: the literal prefix `msg_` followed by a version-4 UUID with its hyphens removed (32 lowercase hex characters, 36 in total). Identical across retries and replays — use it as your idempotency key. To store it as a native UUID, drop the `msg_` prefix and re-insert hyphens at 8-4-4-4-12; the transform is lossless in both directions. Every MailKite id is built this way and differs only in the prefix (`rte_`, `dom_`, `usr_`, …).",
"pattern": "^msg_[0-9a-f]{32}$",
"minLength": 36,
"maxLength": 36,
"examples": [
"msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91"
]
},
"type": {
"type": "string",
"const": "email.received",
"description": "The event type. Always email.received for inbound mail."
},
"from": {
"type": "object",
"description": "The envelope sender.",
"required": [
"address",
"name"
],
"properties": {
"address": {
"type": "string",
"description": "Sender email address, taken from the SMTP envelope (MAIL FROM) — the address we actually routed on.",
"examples": [
"ada@example.com"
]
},
"name": {
"type": [
"string",
"null"
],
"description": "Sender display name, decoded from the MIME From: header. null when the message carried no display name, or when the From: header names a different address than the envelope did — as happens with mailing lists, forwarders, and spoofed mail. It is sender-asserted and unverified, like the Date: header: never use it for authorization, and check the auth block before showing it as an identity.",
"examples": [
"Ada Lovelace"
]
}
},
"additionalProperties": false
},
"to": {
"type": "array",
"description": "The recipient address this delivery is for. One entry per event: a message addressed to several of your addresses is routed and delivered once per matching address.",
"minItems": 1,
"items": {
"type": "object",
"required": [
"address",
"name"
],
"properties": {
"address": {
"type": "string",
"description": "Recipient email address, taken from the SMTP envelope (RCPT TO).",
"examples": [
"support@myapp.ai"
]
},
"name": {
"type": [
"string",
"null"
],
"description": "Recipient display name, decoded from the MIME To: header. null when absent, or when the To: header does not name this exact address (e.g. mail delivered via Bcc or an alias).",
"examples": [
"Support"
]
}
},
"additionalProperties": false
}
},
"subject": {
"type": [
"string",
"null"
],
"description": "Decoded subject line. null when the message had no Subject header."
},
"text": {
"type": [
"string",
"null"
],
"description": "Plain-text body, already MIME-decoded. null when the message had no text/plain part."
},
"html": {
"type": [
"string",
"null"
],
"description": "HTML body, already MIME-decoded. null when the message had no text/html part."
},
"textFromHtml": {
"type": [
"string",
"null"
],
"description": "Plain text WE DERIVED from `html` — not something the sender wrote. Populated only when `text` is null and `html` is not, so it never duplicates a body already present and the consumer's rule is one line: `text ?? textFromHtml`. null whenever `text` is present, or when there was no `html` to convert. About 20% of real inbound mail carries no text/plain part; this is that gap filled without putting words in the sender's mouth — `text` and `html` remain exactly what arrived.",
"examples": [
"Looks good — approved!"
]
},
"threadId": {
"type": [
"string",
"null"
],
"description": "The conversation root: the message's In-Reply-To/References root, falling back to its own Message-ID. Pass it back as send()'s inReplyTo to reply in-thread. null when the message carried no usable id."
},
"receivedAt": {
"type": "integer",
"description": "When MailKite accepted the message, in Unix epoch milliseconds (UTC). This is arrival time, not delivery time — it is read from the stored message, so a retry hours later or a replay months later reports the same instant. Distinct from the sender-supplied Date: header, which is self-asserted and spoofable.",
"examples": [
1785196800000
]
},
"receivedAtIso": {
"type": "string",
"format": "date-time",
"description": "The same instant as receivedAt, rendered as an RFC 3339 / ISO 8601 UTC timestamp.",
"examples": [
"2026-07-28T00:00:00.000Z"
]
},
"auth": {
"type": "object",
"description": "Authentication and spam verdicts. SPF and DKIM are computed at our receiving edge, where the connecting IP and SMTP envelope are still visible; DMARC is derived from them in the Worker (see docs/api/lib/dmarc.md). Every field is ALWAYS PRESENT and is null when that check did not run or produced no verdict — treat null as 'unknown', NEVER as 'pass'. SPF/DKIM values are passed through from the edge rather than re-mapped, so match the ones you care about and treat anything else as unknown. The spam verdict is INFORMATIONAL: we deliver flagged mail to you exactly like any other message and never drop, defer, or withhold a webhook because of it — you have context we don't, so you set the threshold. Use spamSignals to decide what to act on.",
"required": [
"spf",
"dkim",
"dmarc",
"spam",
"spamScore",
"spamSignals"
],
"additionalProperties": false,
"properties": {
"spf": {
"type": [
"string",
"null"
],
"description": "SPF result for the sending IP. Typically pass, fail, softfail, or neutral.",
"examples": [
"pass"
]
},
"dkim": {
"type": [
"string",
"null"
],
"description": "DKIM signature result. Typically pass or fail.",
"examples": [
"pass"
]
},
"dmarc": {
"type": [
"string",
"null"
],
"description": "DMARC evaluation result for the message, derived in the Worker from the SPF and DKIM results plus identifier alignment against the From: header domain (RFC 7489 §4.2). One of: 'pass' (SPF or DKIM passed AND its domain aligned), 'fail' (the From domain publishes a DMARC record and neither identifier aligned and passed), 'none' (the From domain publishes no DMARC record — nothing was asserted, so nothing failed), 'temperror' (the policy lookup itself failed and the answer is unknown). null when evaluation could not run at all. 'none' and 'fail' are NOT the same thing: only 'fail' means a published policy was violated.",
"examples": [
"pass"
]
},
"spam": {
"type": [
"string",
"null"
],
"description": "Spam verdict: 'spam', 'ham', or null when the message was not scored. NEVER affects delivery — this message reached you because we do not act on this field. Edges running rspamd may pass through its action name instead.",
"examples": [
"ham"
]
},
"spamScore": {
"type": [
"number",
"null"
],
"description": "How strongly the message scored as spam, from 0 (clean) to 1. null when not scored. The threshold behind the `spam` verdict is 0.5, but you are free to pick your own — that is the point of publishing the number.",
"minimum": 0,
"maximum": 1,
"examples": [
0.85
]
},
"spamSignals": {
"type": [
"array",
"null"
],
"description": "Which signals fired, and therefore WHY the score is what it is. Stable identifiers you can branch on: dnsbl_listed (the connecting IP is on a blocklist), dbl_listed (the From domain is on a domain blocklist), spf_fail, spf_softfail, dkim_fail, dmarc_fail, unaligned_from (the visible From: domain differs from the envelope and nothing authenticated either), no_auth (no SPF, DKIM or DMARC at all). Empty when nothing fired; null when the message was not scored.",
"items": {
"type": "string"
},
"examples": [
[
"dnsbl_listed",
"spf_fail"
],
[
"dbl_listed"
]
]
}
}
},
"attachments": {
"type": "array",
"description": "Decoded attachments. Empty array when the message had none. Every key below is always present on every entry. `url` and `content` are the one mutually exclusive pair — exactly one of them is non-null: normally `url` (a signed, credential-free GET link valid for 7 days), and `content` (base64 bytes inlined) on zero-retention and at-rest-encrypted domains where no object is stored. Handle both.",
"items": {
"type": "object",
"required": [
"id",
"filename",
"contentType",
"size",
"contentId",
"disposition",
"url",
"content"
],
"additionalProperties": false,
"oneOf": [
{
"properties": {
"url": {
"type": "string"
},
"content": {
"type": "null"
}
}
},
{
"properties": {
"url": {
"type": "null"
},
"content": {
"type": "string"
}
}
}
],
"properties": {
"id": {
"type": [
"string",
"null"
],
"description": "Attachment id, `<messageId>:<index>`. null when the bytes are inlined in `content` (nothing was stored to reference).",
"examples": [
"msg_4f3c1a9e2b7d48e1a05c6f8b3d2e7a91:0"
]
},
"filename": {
"type": [
"string",
"null"
],
"description": "Filename as sent, decoded. null when the part had no name.",
"examples": [
"po.pdf"
]
},
"contentType": {
"type": [
"string",
"null"
],
"description": "MIME type as declared by the sender. null when absent.",
"examples": [
"application/pdf"
]
},
"size": {
"type": "integer",
"description": "Decoded size in bytes.",
"examples": [
18213
]
},
"contentId": {
"type": [
"string",
"null"
],
"description": "The part's Content-ID, unbracketed. Pair it with `disposition: \"inline\"` to map an inline image back to the HTML body's `src=\"cid:<contentId>\"`. null when the part declared none.",
"examples": [
"logo@example.com"
]
},
"disposition": {
"type": [
"string",
"null"
],
"description": "The part's Content-Disposition: 'attachment' or 'inline'. null when the sender declared none.",
"examples": [
"attachment"
]
},
"url": {
"type": [
"string",
"null"
],
"description": "Signed, time-limited GET link — fetch it with no credentials. Valid for 7 days, after which the object is deleted (410 Gone). null when `content` carries the bytes instead.",
"examples": [
"https://api.mailkite.dev/att/4f3c1a9e2b7d48e1a05c6f8b3d2e7a91/0?exp=1754265600&sig=…"
]
},
"content": {
"type": [
"string",
"null"
],
"description": "The file bytes, base64-encoded, inlined because nothing was stored. Non-null only on zero-retention or at-rest-encrypted domains, in place of `url`.",
"contentEncoding": "base64"
}
}
}
}
}
} Tracking: outbound email.* events
One webhook, all events. Opt in per domain with
setWebhookEvents — pass "all" or a
list of event types — and MailKite delivers engagement events to the
same webhook that receives your inbound mail; your handler
switches on the payload's type. Off by default, so an existing
inbound consumer never receives event types it didn't opt into. Prefer
engagement events at their own URL (e.g. framework integrations with
paired inbound/tracking endpoints, like django-anymail)? Set a dedicated
one with setTrackingWebhook instead — when both
are configured, the dedicated URL wins. Events are signed with the same
x-mailkite-signature scheme (and the same secret) as your
inbound deliveries. Delivery is best-effort (one
attempt, no retries) — treat a missed event as an analytics gap, and use
each event's id (evt_…) as your idempotency key.
| Event | Fired when | Extra data fields |
|---|---|---|
email.sent | A message is handed to the provider — one event per to recipient. | messageId, providerMessageId |
email.bounced | The recipient's server rejected the message (provider notification or DSN). | bounce.type (hard · soft), bounce.diagnostic (the DSN/SMTP line) |
email.complained | The recipient marked it as spam (feedback loop). | complaint.feedbackType |
email.opened | The tracking pixel fired (requires trackOpens, HTML only). | open: machine/machineKind, userAgent, client, os, device, country |
email.clicked | A rewritten link was followed (requires trackClicks, HTML only). | click: everything in open plus url (the destination) |
Two things worth knowing. Machine flags: Apple Mail Privacy
Protection, Gmail's image proxy, and security scanners prefetch pixels and
follow links — machine: true marks those hits so you can
exclude them from human open/click counts. Correlation:
email.bounced/email.complained originate from
provider notifications, so data.messageId is
null there — key on data.to (the recipient)
instead. email.delivered is reserved for a future release.
POST
your tracking webhook URL email.* — an outbound engagement event.
Event body
tracking-event.jsonThe JSON body MailKite POSTs to a domain's tracking webhook (setTrackingWebhook) when an engagement event occurs for outbound mail. Verify the x-mailkite-signature header over the raw body (same HMAC scheme and account secret as inbound email.received deliveries) before trusting it. One event per recipient. Delivery is best-effort (single attempt).
| Field | Type | Notes |
|---|---|---|
| id req | string | Unique event id (evt_…) — use it as your idempotency key. |
| type req | "email.sent" · "email.bounced" · "email.complained" · "email.opened" · "email.clicked" | The event type. email.delivered is reserved for a future release. |
| createdAt req | integer | When the event occurred, Unix epoch milliseconds (UTC). |
| createdAtIso req | string (ISO 8601) | The same instant as createdAt, RFC 3339 / ISO 8601 UTC. |
| data req | object | |
| data.messageId req | string · null | The MailKite message id (msg_…, the send() response id). null when the source event can't be correlated back to a stored message — typically provider bounce/complaint notifications, which carry only providerMessageId. |
| data.providerMessageId | string · null | The upstream provider's message id, when the event came from a provider notification. |
| data.from | string · null | |
| data.to req | string | The recipient this event is about. |
| data.subject | string · null | |
| data.bounce | object | Present on email.bounced. |
| data.bounce.type req | "hard" · "soft" | |
| data.bounce.diagnostic | string · null | The DSN / SMTP diagnostic, when the reporting MTA supplied one. |
| data.complaint | object | Present on email.complained. |
| data.complaint.feedbackType | string · null | The feedback-loop complaint type (e.g. abuse), when reported. |
| data.open | object | Present on email.opened. `machine: true` marks proxy/prefetch/scanner hits (Apple MPP, Gmail image proxy, security scanners) — exclude them from human open counts. |
| data.open.machine | boolean | |
| data.open.machineKind | string · null | |
| data.open.userAgent | string · null | |
| data.open.client | string · null | |
| data.open.os | string · null | |
| data.open.device | string · null | |
| data.open.country | string · null | |
| data.click | object | Present on email.clicked. Same machine flagging as opens — security scanners follow every link. |
| data.click.url req | string | The destination the click resolved to. |
| data.click.machine | boolean | |
| data.click.machineKind | string · null | |
| data.click.userAgent | string · null | |
| data.click.client | string · null | |
| data.click.os | string · null | |
| data.click.device | string · null | |
| data.click.country | string · null |
An email.clicked event, exactly as it's POSTed to your tracking webhook — verify x-mailkite-signature over the raw body before trusting it.
{
"id": "evt_5Vp2…",
"type": "email.clicked",
"createdAt": 1785196800000,
"createdAtIso": "2026-07-28T00:00:00.000Z",
"data": {
"messageId": "msg_2Hk9…",
"from": "billing@myapp.ai",
"to": "ada@example.com",
"subject": "Your invoice #1042",
"click": {
"url": "https://myapp.ai/invoices/1042",
"machine": false,
"machineKind": null,
"userAgent": "Mozilla/5.0 (Macintosh…) Chrome/126…",
"client": "Chrome",
"os": "macOS",
"device": "desktop",
"country": "US"
}
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/tracking-event.json",
"title": "email.* tracking webhook event",
"description": "The JSON body MailKite POSTs to a domain's tracking webhook (setTrackingWebhook) when an engagement event occurs for outbound mail. Verify the x-mailkite-signature header over the raw body (same HMAC scheme and account secret as inbound email.received deliveries) before trusting it. One event per recipient. Delivery is best-effort (single attempt).",
"type": "object",
"required": [
"id",
"type",
"createdAt",
"createdAtIso",
"data"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Unique event id (evt_…) — use it as your idempotency key.",
"examples": [
"evt_2Hk9QpVn4tLd"
]
},
"type": {
"type": "string",
"enum": [
"email.sent",
"email.bounced",
"email.complained",
"email.opened",
"email.clicked"
],
"description": "The event type. email.delivered is reserved for a future release."
},
"createdAt": {
"type": "integer",
"description": "When the event occurred, Unix epoch milliseconds (UTC)."
},
"createdAtIso": {
"type": "string",
"format": "date-time",
"description": "The same instant as createdAt, RFC 3339 / ISO 8601 UTC."
},
"data": {
"type": "object",
"required": [
"messageId",
"to"
],
"additionalProperties": false,
"properties": {
"messageId": {
"type": [
"string",
"null"
],
"description": "The MailKite message id (msg_…, the send() response id). null when the source event can't be correlated back to a stored message — typically provider bounce/complaint notifications, which carry only providerMessageId."
},
"providerMessageId": {
"type": [
"string",
"null"
],
"description": "The upstream provider's message id, when the event came from a provider notification."
},
"from": {
"type": [
"string",
"null"
]
},
"to": {
"type": "string",
"description": "The recipient this event is about."
},
"subject": {
"type": [
"string",
"null"
]
},
"bounce": {
"type": "object",
"description": "Present on email.bounced.",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"hard",
"soft"
]
},
"diagnostic": {
"type": [
"string",
"null"
],
"description": "The DSN / SMTP diagnostic, when the reporting MTA supplied one."
}
}
},
"complaint": {
"type": "object",
"description": "Present on email.complained.",
"properties": {
"feedbackType": {
"type": [
"string",
"null"
],
"description": "The feedback-loop complaint type (e.g. abuse), when reported."
}
}
},
"open": {
"type": "object",
"description": "Present on email.opened. `machine: true` marks proxy/prefetch/scanner hits (Apple MPP, Gmail image proxy, security scanners) — exclude them from human open counts.",
"properties": {
"machine": {
"type": "boolean"
},
"machineKind": {
"type": [
"string",
"null"
]
},
"userAgent": {
"type": [
"string",
"null"
]
},
"client": {
"type": [
"string",
"null"
]
},
"os": {
"type": [
"string",
"null"
]
},
"device": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
}
}
},
"click": {
"type": "object",
"description": "Present on email.clicked. Same machine flagging as opens — security scanners follow every link.",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"description": "The destination the click resolved to."
},
"machine": {
"type": "boolean"
},
"machineKind": {
"type": [
"string",
"null"
]
},
"userAgent": {
"type": [
"string",
"null"
]
},
"client": {
"type": [
"string",
"null"
]
},
"os": {
"type": [
"string",
"null"
]
},
"device": {
"type": [
"string",
"null"
]
},
"country": {
"type": [
"string",
"null"
]
}
}
}
}
}
}
}
A bounce, for contrast — no engagement block, a null messageId, and the DSN diagnostic your suppression logic wants:
{
"id": "evt_8Rw2…",
"type": "email.bounced",
"createdAt": 1785196800000,
"createdAtIso": "2026-07-28T00:00:00.000Z",
"data": {
"messageId": null,
"providerMessageId": "0100019f…-000000",
"to": "gone@example.com",
"bounce": { "type": "hard", "diagnostic": "550 5.1.1 user unknown" }
}
} Attachments
Inbound attachments are served from signed, time-limited URLs returned in the webhook and Messages API — no credential needed to fetch them:
GET /att/:mid/:idx?exp=…&sig=…
Links are valid for 7 days, then the object is deleted. An expired link
returns 410; a tampered one returns 403.