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.
| Field | Type | Notes |
|---|---|---|
| id req | string | Stable message id (msg_…). Identical across retries — use it as your idempotency key. |
| 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 | string | Sender display name, decoded from the MIME From: header. OMITTED (not 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 | string | Recipient display name, decoded from the MIME To: header. Omitted (not null) when absent, or when the To: header does not name this exact address (e.g. mail delivered via Bcc or an alias). |
| subject | string · null | Decoded subject line. null when the message had no Subject header. |
| text | string · null | Plain-text body, already MIME-decoded. null when the message had no text/plain part. |
| html | string · null | HTML body, already MIME-decoded. null when the message had no text/html part. |
| threadId | 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 computed at our receiving edge, where the connecting IP and SMTP envelope are still visible. Any field is null when that check did not run or produced no verdict — treat null as 'unknown', never as 'pass'. Values are passed through from the edge rather than re-mapped, so match the ones you care about and treat anything else as unknown. |
| 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 alignment result, derived from SPF and DKIM. Typically pass or fail. |
| auth.spam req | string · null | Spam verdict. Typically ham or spam; edges running rspamd may pass through its action name instead. |
| attachments req | any[] | Decoded attachments. Empty when the message had none. Each entry carries EXACTLY ONE of `url` (the normal case: a signed, credential-free GET link valid for 7 days) or `content` (base64 bytes inlined, used on zero-retention and at-rest-encrypted domains where no object is stored). Handle both. |
| attachments[].id | string | Attachment id, `<messageId>:<index>`. Absent when the bytes are inlined (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[].url | string | Signed, time-limited GET link — fetch it with no credentials. Valid for 7 days, after which the object is deleted (410 Gone). Present unless `content` is. |
| attachments[].content | string (base64) | The file bytes, base64-encoded, inlined because nothing was stored. Present 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_2Hk9…",
"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>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
} {
"$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.",
"type": "object",
"required": [
"id",
"type",
"from",
"to",
"receivedAt",
"receivedAtIso",
"auth",
"attachments"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Stable message id (msg_…). Identical across retries — use it as your idempotency key.",
"examples": [
"msg_2Hk9QpVn4tLd"
]
},
"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"
],
"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",
"description": "Sender display name, decoded from the MIME From: header. OMITTED (not 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"
]
}
}
},
"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"
],
"properties": {
"address": {
"type": "string",
"description": "Recipient email address, taken from the SMTP envelope (RCPT TO).",
"examples": [
"support@myapp.ai"
]
},
"name": {
"type": "string",
"description": "Recipient display name, decoded from the MIME To: header. Omitted (not 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"
]
}
}
}
},
"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."
},
"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 computed at our receiving edge, where the connecting IP and SMTP envelope are still visible. Any field is null when that check did not run or produced no verdict — treat null as 'unknown', never as 'pass'. Values are passed through from the edge rather than re-mapped, so match the ones you care about and treat anything else as unknown.",
"required": [
"spf",
"dkim",
"dmarc",
"spam"
],
"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 alignment result, derived from SPF and DKIM. Typically pass or fail.",
"examples": [
"pass"
]
},
"spam": {
"type": [
"string",
"null"
],
"description": "Spam verdict. Typically ham or spam; edges running rspamd may pass through its action name instead.",
"examples": [
"ham"
]
}
}
},
"attachments": {
"type": "array",
"description": "Decoded attachments. Empty when the message had none. Each entry carries EXACTLY ONE of `url` (the normal case: a signed, credential-free GET link valid for 7 days) or `content` (base64 bytes inlined, used on zero-retention and at-rest-encrypted domains where no object is stored). Handle both.",
"items": {
"type": "object",
"required": [
"filename",
"contentType",
"size"
],
"additionalProperties": false,
"oneOf": [
{
"required": [
"url"
],
"not": {
"required": [
"content"
]
}
},
{
"required": [
"content"
],
"not": {
"required": [
"url"
]
}
}
],
"properties": {
"id": {
"type": "string",
"description": "Attachment id, `<messageId>:<index>`. Absent when the bytes are inlined (nothing was stored to reference).",
"examples": [
"msg_2Hk9QpVn4tLd:0"
]
},
"filename": {
"type": [
"string",
"null"
],
"description": "Filename as sent, decoded. null when the part had no name."
},
"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."
},
"url": {
"type": "string",
"format": "uri",
"description": "Signed, time-limited GET link — fetch it with no credentials. Valid for 7 days, after which the object is deleted (410 Gone). Present unless `content` is.",
"examples": [
"https://api.mailkite.dev/att/2Hk9QpVn4tLd/0?exp=1754265600&sig=…"
]
},
"content": {
"type": "string",
"contentEncoding": "base64",
"description": "The file bytes, base64-encoded, inlined because nothing was stored. Present only on zero-retention or at-rest-encrypted domains, in place of `url`."
}
}
}
}
}
} 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.