Feature guides
Webhooks
Get signed, retried HTTP callbacks for mail and account events, receive mail at unmatched addresses, and verify every request.
What webhooks are
A webhook endpoint is an HTTPS URL of yours that mila calls whenever something
happens in your organization: a mailbox is created, a message you sent is
delivered or bounces, a message arrives for an address you routed to your
code. Each event is recorded durably first and then delivered to every
matching endpoint as a signed POST. A failed delivery is not lost -- mila
retries it for five days, keeps it for 30, and lets you resend it by hand.
- Who can manage webhooks. The organization owner, or an admin who has access to every domain. Members limited to some domains cannot see webhooks at all, because an endpoint receives events (and, for inbound mail, message content) from every domain it subscribes to.
- How many. Up to 10 endpoints per organization.
- Where. In the panel under Webhooks; through the public API under
/api/v1/webhooks(see below); and from mila's MCP server, which has webhook tools for the same operations (see Integrating with mila's API and MCP server).
Each endpoint has a URL, an optional description, the event types it wants (or
all of them), an optional list of domains to restrict it to (empty means every
domain), an inbound mail format (raw or json, see "Receiving mail"), and an
enabled switch. When you create it, mila shows you its signing secret once
-- copy it then; see "Verifying signatures".
The API
API keys need the webhooks:read and/or webhooks:write permission, and the
key's owner must be an owner or full-access admin of the organization. Keys
created before permissions existed do not have these -- create a new key.
| Call | Scope | What it does |
|---|---|---|
GET /api/v1/webhook-event-types | webhooks:read | The catalog of event types and their groups |
GET /api/v1/webhooks, GET /api/v1/webhooks/{id} | webhooks:read | List endpoints (with 24 h health) or read one |
POST /api/v1/webhooks | webhooks:write | Create; the response carries the secret once |
PATCH /api/v1/webhooks/{id}, DELETE /api/v1/webhooks/{id} | webhooks:write | Change or delete (deleting drops its pending deliveries) |
POST /api/v1/webhooks/{id}/rotate-secret | webhooks:write | New secret; the old one keeps signing for 24 hours |
POST /api/v1/webhooks/{id}/test | webhooks:write | Send one webhook.test event now and report the result |
GET /api/v1/webhooks/{id}/deliveries, .../deliveries/{deliveryId} | webhooks:read | Delivery log, filterable by status, eventType, createdAfter, createdBefore |
POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry | webhooks:write | Resend one delivery |
POST /api/v1/webhooks/{id}/deliveries/retry | webhooks:write | Resend many by filter (up to 1000 per call; hasMore tells you to call again) |
GET /api/v1/domains/{domainId}/inbound-webhook | domains:read | Whether "forward unmatched addresses to webhook" is on ({ "enabled": true }) |
PUT /api/v1/domains/{domainId}/inbound-webhook | domains:write (admin) | Turn "forward unmatched addresses to webhook" on or off ({ "enabled": true }). 409 CATCHALL_ACTIVE / DOMAIN_ALIAS_ACTIVE; 503 MAIL_SERVER_UNAVAILABLE if the mail server could not apply the + sub-addressing change (turning it on then changed nothing, so repeat; turning it off is already off and + is restored automatically within a few minutes) |
Creating an endpoint:
curl -X POST "https://api.mila.cx/api/v1/webhooks" \
-H "Authorization: Bearer $MILA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/mila",
"description": "Production events",
"eventTypes": ["email.sent", "email.delivered", "email.bounced", "mailbox.created"]
}'
eventTypes is a non-empty list of types from the catalog below, or exactly
["*"] for all of them ("*" cannot be combined with other types).
webhook.test is sent only by the test action and cannot be subscribed to.
Limits: url at most 2048 characters, description at most 500, domainIds
at most 500 entries.
Events
Every event is a JSON document in the same envelope:
{
"id": "evt_01JBQ6Z9X4M2K7R8T5V3N1W0YC",
"type": "mailbox.created",
"createdAt": "2026-10-30T09:15:42.118Z",
"organizationId": "<organization id>",
"data": { "mailboxId": "...", "domainId": "...", "address": "sales@example.com" }
}
id is the same on every delivery of the event to any of your endpoints.
(email.received with the raw format is the one exception to the JSON
envelope; see "Receiving mail".)
| Event | When it fires | data fields |
|---|---|---|
email.received | A message arrives. For a mailbox address on the domain: metadata only. For an unmatched address on a domain with "forward unmatched addresses to webhook" on: the whole message, see "Receiving mail". | Metadata: messageId, from, to, subject, size, format: "metadata". Unmatched, raw format: no JSON body (the message is the body). Unmatched, json format: messageId, from, subject, size, format: "raw", plus raw (base64) or rawOmitted |
email.sent | One of your domains' authenticated senders (SMTP login, webmail, the REST API, MCP) submitted a message. One event per message, listing all recipients | queueId, messageId, from, recipients[], subject (an API send that mila had to record itself carries submissionId instead of queueId) |
email.delivered | The remote server accepted the message for one recipient. A recipient that your own routing took (alias, rewrite, forward, or an unmatched address on a domain with "forward unmatched addresses to webhook" on) is reported delivered at once with the reply 250 2.1.5 Accepted for local routing | queueId, submissionId (API sends), messageId, recipient, smtpReply |
email.delivery_delayed | A temporary failure; mila is still retrying. At most once per recipient per 6 hours | same as email.delivered |
email.bounced | A permanent failure for one recipient | same as email.delivered |
email.delivery_unknown | mila could not find out what happened to a recipient: see the guarantees below | same as email.delivered |
domain.created | A domain was added to the organization | domainId, name |
domain.verified | A domain became active. This fires every time a domain goes from not active to active, including re-activation of a deactivated domain, not only the first time | domainId, name |
domain.dns_issue | An active domain whose MX, SPF and DKIM were all healthy stopped being healthy. It fires once per regression, not on every check while it stays broken | domainId, name, issues[] (record, expected, found) |
domain.deleted | A domain was removed from the organization | domainId, name |
mailbox.created | A mailbox was created (hidden system mailboxes never produce events) | mailboxId, domainId, address |
mailbox.updated | A mailbox setting changed, including a password change or an expiry deactivating it | mailboxId, domainId, address, changes[] -- the names of the changed fields (such as may_send, active, password), never their values |
mailbox.deleted | A mailbox was deleted (with its original address) | mailboxId, domainId, address |
alias.created, alias.deleted | An alias was added or removed | aliasId, domainId, address, destinations[] |
forwarding.created, forwarding.deleted | A forwarding rule was added or removed | forwardingId, mailboxAddress, destination |
webhook.test | You pressed "send test" (panel or API) | message, endpointId |
Things worth knowing:
- Domain transfers. Moving a domain to another organization emits
domain.deletedin the source organization anddomain.createdin the target organization, both with"transferred": trueindata, so you can tell a move from a real deletion or creation. - No per-row cascade events. When a domain or a mailbox is deleted, the
rows that go with it (aliases, forwardings, and so on) do not emit their own
alias.deleted/forwarding.deletedevents. Treatdomain.deletedandmailbox.deletedas covering everything underneath. - Not events. Updating an alias, updating a forwarding rule, and a forwarding confirmation do not produce events.
- No ordering. Events can arrive out of order. Use
createdAtif the order matters to you.
What is guaranteed
Be precise about what "durable" means here:
| Event | Guarantee |
|---|---|
email.received | Guaranteed. It is recorded before mila accepts the message. If mila cannot write the record, it answers the sending server with a temporary SMTP error (4xx) and the sender tries again. (Metadata events for mailbox addresses are only recorded while one of your endpoints subscribes to email.received for that domain; a newly created or re-filtered endpoint can miss up to about 30 seconds of them. Unmatched-address mail is always recorded.) |
email.sent | Guaranteed for authenticated sends (SMTP login on 587, webmail, the REST API, MCP) while one of your endpoints subscribes to an outbound event (email.sent, email.delivered, email.delivery_delayed, email.bounced, email.delivery_unknown, or all events) for the sender's domain. It is recorded before the message is accepted; if it cannot be, the submission gets a temporary SMTP error rather than an accepted-but-unrecorded message. Messages sent while no endpoint subscribes are not tracked at all -- no email.sent and no outcome events for them later -- and nothing is back-filled when you add an endpoint; a newly created or re-filtered endpoint can miss up to about 30 seconds of sends. |
email.delivered, email.delivery_delayed, email.bounced | Near-guaranteed. The outcome comes from the mail server's telemetry, which is held in memory: it can be lost only if the mail server restarts at the wrong moment. A sweep every 5 minutes then checks the mail queue for every message older than 10 minutes and recovers the outcome when it can. |
email.delivery_unknown | Guaranteed, and explicit. If a recipient's message has left the queue and no outcome was ever seen, mila waits a grace period of about 15 minutes and then tells you "unknown" instead of staying silent. It means "we do not know", not "it failed": check the recipient's side. |
domain.*, mailbox.*, alias.*, forwarding.* | Guaranteed. The event is written in the same database transaction as the change itself: either both happen or neither does. |
Delivery
mila delivers at least once. A receiver can see the same delivery more than once (a retry after a timeout whose response you did send, a crash during delivery) and must be idempotent. There is no ordering guarantee between events.
Every request is a POST with these headers:
| Header | Meaning |
|---|---|
X-Mila-Event | The event type, such as mailbox.created |
X-Mila-Event-Id | The event's id (evt_...), shared by all endpoints that get it |
X-Mila-Delivery-Id | The id of this delivery. Stable across every retry -- deduplicate on it |
X-Mila-Attempt | 1 for the first attempt, then 2, 3, ... (a manual resend keeps counting) |
X-Mila-Timestamp | Unix seconds when this attempt was signed |
X-Mila-Signature | v1=<hex>; two comma-separated values during a secret rotation |
X-Mila-Recipient | Only on email.received: the envelope recipient, exactly as received when it is plain ASCII (always the case for addresses such as rsvp+<token>@...). An address with non-ASCII characters (for example şükrü@...) is sent percent-encoded UTF-8 (%C5%9F%C3%BCkr%C3%BC%40..., as JavaScript's encodeURIComponent produces it): such a value never contains a literal @, so a header without @ is decoded with decodeURIComponent / urllib.parse.unquote |
User-Agent | Mila-Webhooks/1.0 |
The body is the JSON envelope (Content-Type: application/json), or the raw
message for email.received in raw format.
How your response is read
- Your server has 10 seconds to answer. mila does not follow redirects.
- 2xx -- delivered. Anything in the body is ignored.
- 4xx except 408 and 429 (and any 3xx) -- rejected: you said no, so mila does not retry. The delivery is kept so you can fix your side and resend it.
- 408, 429, 5xx, timeouts, network and TLS errors -- retried. A
429response'sRetry-Afteris honored as a minimum wait, up to 6 hours. - DNS failures and destinations that mila refuses to connect to (see "Security") are retried the same way.
Retries happen 1 minute, 5 minutes, 15 minutes, 1 hour and 3 hours after the previous attempt, then every 6 hours, each with +/-10 % jitter, until 5 days after the first attempt. When the next retry would land past that window the delivery becomes failed and mila's operators are alerted (the alert carries identifiers and counts, never message content or recipients).
A delivery ends in one of these states:
| State | Meaning |
|---|---|
pending, delivering | Queued, or being sent right now |
delivered | You answered 2xx |
rejected | You answered 4xx (except 408/429) |
failed | Five days of retries ran out (or it was held longer than 30 days on a paused endpoint, or -- error raw_message_purged -- the raw message of an email.received was already deleted, see below) |
too_large | The body would exceed 1 MiB; see "Receiving mail" |
cancelled | The endpoint was edited so it no longer matches the event before it was sent |
Finished deliveries, their attempts and the event bodies are kept for 30
days. In that time you can resend any of them (even a delivered one)
from the delivery log in the panel, one by one, or in bulk by filter (a bulk
resend without a status picks up failed, rejected and too_large). A
resend goes out with the same X-Mila-Delivery-Id, a fresh 5-day retry
window, and a continuing attempt count. webhook.test deliveries cannot be
resent -- send a new test. A raw email.received delivery can be resent
only while mila still stores the message: the stored copy is deleted as soon as
every delivery of it has succeeded (otherwise 30 days after the last one
finished, or with its domain). After that a resend is refused with 409
RAW_MESSAGE_PURGED, and a delivery that still reaches the sender (a bulk
resend) ends at once as failed with the error raw_message_purged, without
retries.
Pausing an endpoint
Turning an endpoint off pauses it; it does not drop events. Matching events
keep being recorded and wait as pending. When you turn the endpoint back on
they are sent, each with a fresh 5-day retry window. A delivery held on a paused
endpoint for more than 30 days becomes failed (with the error
endpoint_paused_too_long) and is then kept for 30 more days like any other
failure. Deleting an endpoint is different: it drops the endpoint and all
of its deliveries, pending ones included, which are then never sent. Pause
instead of deleting if you may want the events later.
Verifying signatures
Anyone who learns your URL can send it a fake request, so verify every one.
The signature is an HMAC-SHA256 over a string built from three things:
signed string = X-Mila-Timestamp + "\n" + X-Mila-Recipient + "\n" + hex(sha256(raw body))
X-Mila-Signature = "v1=" + lowercase hex( HMAC-SHA256(key, signed string) )
X-Mila-Recipientis the empty string when the header is absent (every event exceptemail.received). Use the header value exactly as received -- for a non-ASCII address that is the percent-encoded form; decode it only after verifying.- The key is the 64-character hex secret decoded into 32 bytes; do not use the hex text itself as the key.
- Hash the exact bytes you received. Do not parse and re-serialize the JSON first.
- During a secret rotation, the header holds two values, the new secret's and
the old one's:
v1=<new>,v1=<old>. Accept the request if anyv1value matches. - Reject requests whose
X-Mila-Timestampis more than about 5 minutes from your clock, to defeat replays, and compare signatures in constant time.
Node.js:
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyMilaWebhook({ secretHex, headers, rawBody, now = Date.now() / 1000 }) {
const timestamp = headers['x-mila-timestamp'];
const recipient = headers['x-mila-recipient'] ?? '';
const signature = headers['x-mila-signature'] ?? '';
// Reject stale or far-future timestamps (replay protection).
if (!/^\d+$/.test(timestamp ?? '') || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const bodyHash = createHash('sha256').update(rawBody).digest('hex');
const expected = createHmac('sha256', Buffer.from(secretHex, 'hex'))
.update(`${timestamp}\n${recipient}\n${bodyHash}`)
.digest('hex');
// The header may carry two values during a secret rotation: "v1=<new>,v1=<old>".
let ok = false;
for (const part of signature.split(',')) {
const candidate = part.trim();
if (!candidate.startsWith('v1=')) continue;
const got = Buffer.from(candidate.slice(3), 'utf8');
const want = Buffer.from(expected, 'utf8');
if (got.length === want.length && timingSafeEqual(got, want)) ok = true;
}
return ok;
}
Python:
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_mila_webhook(secret_hex: str, headers: dict, raw_body: bytes, now: float | None = None) -> bool:
now = time.time() if now is None else now
timestamp = headers.get("x-mila-timestamp", "")
recipient = headers.get("x-mila-recipient", "")
signature = headers.get("x-mila-signature", "")
# Reject stale or far-future timestamps (replay protection).
if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
body_hash = hashlib.sha256(raw_body).hexdigest()
expected = hmac.new(
bytes.fromhex(secret_hex),
f"{timestamp}\n{recipient}\n{body_hash}".encode(),
hashlib.sha256,
).hexdigest()
# The header may carry two values during a secret rotation: "v1=<new>,v1=<old>".
ok = False
for part in signature.split(","):
part = part.strip()
if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
ok = True
return ok
Check your implementation against this test vector. With the secret
0000000000000000000000000000000000000000000000000000000000000000 (64 zeros,
i.e. 32 zero bytes), timestamp 1761800000, recipient
rsvp+abc.def@reply.vnnx.app and body hello, the header must be:
X-Mila-Signature: v1=025d4e1d55076d9a8c7322e8ab58c2f80bebe174300ab0604b305dd071f31f9c
(Both snippets above print exactly this value for those inputs.)
Rotating the secret
Rotate from the panel or with POST /api/v1/webhooks/{id}/rotate-secret. The
new secret is shown once. For the next 24 hours every delivery -- including
retries already in progress -- is signed with both, so you can deploy the new
secret without dropping events. A second rotation inside that window is
refused with 409 ROTATION_IN_PROGRESS, because it would retire a secret you
may still be switching away from; send { "force": true } to override, which
stops the older secret signing immediately.
Receiving mail (email.received)
There are two kinds of email.received.
Mailbox deliveries -- metadata only. When a message is addressed to a real
mailbox of a domain your endpoint covers, you get a JSON event with the sender,
To, subject, Message-ID and size -- no body, no attachments. It is a
notification, not a copy of the mail.
Unmatched addresses -- the whole message. On a domain you can switch on
Forward unmatched addresses to webhook (panel: the domain's settings;
API: PUT /api/v1/domains/{domainId}/inbound-webhook with
{ "enabled": true }). Mail to any address on that domain that has no
mailbox, alias or identity is then accepted and delivered to your
email.received endpoints with the complete message, instead of being rejected.
This is how you build "reply by email" features such as rsvp+<token>@reply.example.com.
The endpoint's inboundFormat picks the shape:
raw(default): the body is the message (Content-Type: message/rfc822), unmodified apart from the header changes listed below, with onePOSTper envelope recipient: a message addressed to two unmatched addresses produces twoPOSTs, each with its ownX-Mila-Recipientheader holding that full address, including any+tag. A message over 1 MiB cannot be sent: that delivery ends astoo_large.json: the usual JSON envelope, with the message base64-encoded indata.raw. If the whole envelope would exceed 1 MiB,data.rawis left out anddata.rawOmittedis"too_large"instead; the metadata is still delivered.
Things to know when you turn it on:
- mila does not bounce this mail. It is accepted and handed to your endpoint, with the same retry rules as everything else. If your endpoint is down, mila keeps the message and retries; the sender is not told. (A genuine database failure while accepting it makes mila answer with a temporary 4xx, so the sender retries.) One exception: mail the mail server generates itself and sends without passing mila's routing -- a Sieve redirect, vacation reply or notification from a mila mailbox, or a delivery status notification -- is rejected for an unmatched address and bounces back to its sender.
- Use
X-Mila-Recipient, notDelivered-To. mila routes this mail through an internal account, so the raw message'sDelivered-Toheader names that account (#inbound@<your domain>), not the address the mail was sent to. The address it was sent to is inX-Mila-Recipient(and in theTo/Ccheaders the sender wrote, which a sender controls). - It is mutually exclusive with a catch-all, and cannot be turned on for a
domain that is an alias of another domain: you get a
409(CATCHALL_ACTIVEorDOMAIN_ALIAS_ACTIVE). - It turns off
+sub-addressing for that domain. Otherwisersvp+abc@domainwould be treated asrsvp@domain. After you turn it on,name+tag@domainno longer reaches the mailboxname: it is just another unmatched address and goes to the webhook. Turning the setting off restores+addressing. - The stored message is deleted once all of its deliveries are
delivered; otherwise 30 days after its last delivery ended.
Trusting Authentication-Results on raw mail
If your code decides things from authentication results -- "did this really
come from that sender?" -- read this part carefully. A sender can write any
headers it likes, including a forged Authentication-Results (A-R) header.
mila gives you one rule to rely on:
Every raw message mila delivers has exactly one mila-written
Authentication-Resultsheader, with the authserv-idmx1.mila.cx, and the firstAuthentication-Resultsheader in the message is mila's. Trust only that first one.
mila enforces it for every message:
- A message that arrived over ordinary inbound SMTP (an unauthenticated
sender) keeps the verdict mila's own mail server wrote at the top (SPF, DKIM
and DMARC results). If, for any reason, it carries no such verdict above
the sender's own headers, it gets the
noneheader described next. - A message from a mila-authenticated sender (someone logged in to mila
SMTP, webmail or the API, writing to your inbound domain) or any other
internal path has no external verdict to report. Those messages get
Authentication-Results: mx1.mila.cx; noneon top.nonemeans exactly what it says: nothing was verified, so do not treat the sender as proven. - Any other
Authentication-Resultsheader whose authserv-id ismx1.mila.cxormx2.mila.cx-- which can only have been planted by a sender, or left by an earlier hop -- is renamed toX-Mila-Neutralized-Authentication-Results, so it cannot be mistaken for a verdict. - mila also removes its own internal
X-Mila-Inbound-Idheader and its ownResent-Message-IDmarker before delivery, and replaces any bare carriage return (a CR not followed by a line feed) inside the header block with a space. Everything after the header block is passed on untouched, and the signature covers the message exactly as sent.
So: take the first Authentication-Results header in the message, check its
authserv-id is mx1.mila.cx, and read the verdict from it; ignore all others.
From the MCP server
mila's MCP server has twelve webhook tools: list_webhook_event_types,
list_webhooks, get_webhook, create_webhook, update_webhook,
delete_webhook, rotate_webhook_secret, send_webhook_test,
list_webhook_deliveries, retry_webhook_delivery,
retry_webhook_deliveries and set_domain_inbound_webhook. They follow the
same management rule as the panel and the API: only the owner, or an admin with
access to every domain, and only through an authorization granted for all
domains.
- Scopes.
webhooks:readfor the read tools,webhooks:writefor the rest (set_domain_inbound_webhookalso needsdomains:write), and the sensitivewebhooks:secretsfor signing secrets. - Secrets.
create_webhookandrotate_webhook_secretreturn the signing secret only to an authorization that includeswebhooks:secrets. Without it,create_webhookstill creates the endpoint but returnssecret: null(rotate it in the panel to see the secret), androtate_webhook_secretis refused, because the receiver's current secret would stop working after 24 h without the client ever seeing the new one. - Mail webhooks need
mail:read. An endpoint that receivesemail.*events (or*) carries message metadata, and raw mail for inbound-routed domains. So creating or changing a mail-subscribed webhook (even a paused one) -- changing its URL, events, domains or format, or enabling it -- and turning on inbound routing withset_domain_inbound_webhook, also requiresmail:readon an authorization with access to all addresses. Retrying anemail.*delivery needs the same (a bulk retry without it skipsemail.*deliveries). Only pausing an endpoint or editing its description is exempt.
Security
- Creation-time URL rules. The URL must be
https://, at most 2048 characters, and must not containuser:pass@credentials. An IP address in a private or internal range (such ashttps://10.0.0.1/,https://[::1]/orhttps://2130706433/) is refused when you create the endpoint. - No internal addresses. On every attempt, mila resolves your hostname,
refuses private, loopback, link-local, carrier-grade NAT, multicast and cloud
metadata addresses (including IPv4-mapped IPv6 ones), and connects to the
address it checked, so rebinding the name after the check does not help.
Hostnames are not resolved when you create the endpoint, because DNS can
change: a name that resolves to a private address fails that attempt with a
retryable
blocked_destinationerror. - Mail content. An endpoint subscribed to
email.*events receives message metadata (and the raw message for inbound-routed domains). That is why only owners and admins with access to all domains can manage webhooks, and why API keys that manage them should be treated like mail access. - The secret is shown once, at creation and at each rotation. mila stores it encrypted and cannot show it to you again; the panel shows only its last four characters. Rotate it if it leaks.
- Responses are not trusted with anything: mila stores at most the first 1 KiB of your response body for the delivery log, and does not follow redirects.