Get your API key
API reference

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.json

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.

FieldTypeNotes
id req stringStable 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 objectThe envelope sender.
from.address req stringSender email address, taken from the SMTP envelope (MAIL FROM) — the address we actually routed on.
from.namestringSender 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 stringRecipient email address, taken from the SMTP envelope (RCPT TO).
to[].namestringRecipient 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).
subjectstring · nullDecoded subject line. null when the message had no Subject header.
textstring · nullPlain-text body, already MIME-decoded. null when the message had no text/plain part.
htmlstring · nullHTML body, already MIME-decoded. null when the message had no text/html part.
threadIdstring · nullThe 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 integerWhen 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 objectAuthentication 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 · nullSPF result for the sending IP. Typically pass, fail, softfail, or neutral.
auth.dkim req string · nullDKIM signature result. Typically pass or fail.
auth.dmarc req string · nullDMARC alignment result, derived from SPF and DKIM. Typically pass or fail.
auth.spam req string · nullSpam 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[].idstringAttachment id, `<messageId>:<index>`. Absent when the bytes are inlined (nothing was stored to reference).
attachments[].filename req string · nullFilename as sent, decoded. null when the part had no name.
attachments[].contentType req string · nullMIME type as declared by the sender. null when absent.
attachments[].size req integerDecoded size in bytes.
attachments[].urlstringSigned, 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[].contentstring (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`.

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.

EventFired whenExtra data fields
email.sentA message is handed to the provider — one event per to recipient.messageId, providerMessageId
email.bouncedThe recipient's server rejected the message (provider notification or DSN).bounce.type (hard · soft), bounce.diagnostic (the DSN/SMTP line)
email.complainedThe recipient marked it as spam (feedback loop).complaint.feedbackType
email.openedThe tracking pixel fired (requires trackOpens, HTML only).open: machine/machineKind, userAgent, client, os, device, country
email.clickedA 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.json

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).

FieldTypeNotes
id req stringUnique 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 integerWhen 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 · nullThe 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.providerMessageIdstring · nullThe upstream provider's message id, when the event came from a provider notification.
data.fromstring · null
data.to req stringThe recipient this event is about.
data.subjectstring · null
data.bounceobjectPresent on email.bounced.
data.bounce.type req "hard" · "soft"
data.bounce.diagnosticstring · nullThe DSN / SMTP diagnostic, when the reporting MTA supplied one.
data.complaintobjectPresent on email.complained.
data.complaint.feedbackTypestring · nullThe feedback-loop complaint type (e.g. abuse), when reported.
data.openobjectPresent 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.machineboolean
data.open.machineKindstring · null
data.open.userAgentstring · null
data.open.clientstring · null
data.open.osstring · null
data.open.devicestring · null
data.open.countrystring · null
data.clickobjectPresent on email.clicked. Same machine flagging as opens — security scanners follow every link.
data.click.url req stringThe destination the click resolved to.
data.click.machineboolean
data.click.machineKindstring · null
data.click.userAgentstring · null
data.click.clientstring · null
data.click.osstring · null
data.click.devicestring · null
data.click.countrystring · null

A bounce, for contrast — no engagement block, a null messageId, and the DSN diagnostic your suppression logic wants:

email.bounced
{
"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:

http
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.