mila
PricingGuidesMCPSDKAbout
Sign inSign up

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.

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.

CallScopeWhat it does
GET /api/v1/webhook-event-typeswebhooks:readThe catalog of event types and their groups
GET /api/v1/webhooks, GET /api/v1/webhooks/{id}webhooks:readList endpoints (with 24 h health) or read one
POST /api/v1/webhookswebhooks:writeCreate; the response carries the secret once
PATCH /api/v1/webhooks/{id}, DELETE /api/v1/webhooks/{id}webhooks:writeChange or delete (deleting drops its pending deliveries)
POST /api/v1/webhooks/{id}/rotate-secretwebhooks:writeNew secret; the old one keeps signing for 24 hours
POST /api/v1/webhooks/{id}/testwebhooks:writeSend one webhook.test event now and report the result
GET /api/v1/webhooks/{id}/deliveries, .../deliveries/{deliveryId}webhooks:readDelivery log, filterable by status, eventType, createdAfter, createdBefore
POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retrywebhooks:writeResend one delivery
POST /api/v1/webhooks/{id}/deliveries/retrywebhooks:writeResend many by filter (up to 1000 per call; hasMore tells you to call again)
GET /api/v1/domains/{domainId}/inbound-webhookdomains:readWhether "forward unmatched addresses to webhook" is on ({ "enabled": true })
PUT /api/v1/domains/{domainId}/inbound-webhookdomains: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".)

EventWhen it firesdata fields
email.receivedA 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.sentOne of your domains' authenticated senders (SMTP login, webmail, the REST API, MCP) submitted a message. One event per message, listing all recipientsqueueId, messageId, from, recipients[], subject (an API send that mila had to record itself carries submissionId instead of queueId)
email.deliveredThe 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 routingqueueId, submissionId (API sends), messageId, recipient, smtpReply
email.delivery_delayedA temporary failure; mila is still retrying. At most once per recipient per 6 hourssame as email.delivered
email.bouncedA permanent failure for one recipientsame as email.delivered
email.delivery_unknownmila could not find out what happened to a recipient: see the guarantees belowsame as email.delivered
domain.createdA domain was added to the organizationdomainId, name
domain.verifiedA 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 timedomainId, name
domain.dns_issueAn 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 brokendomainId, name, issues[] (record, expected, found)
domain.deletedA domain was removed from the organizationdomainId, name
mailbox.createdA mailbox was created (hidden system mailboxes never produce events)mailboxId, domainId, address
mailbox.updatedA mailbox setting changed, including a password change or an expiry deactivating itmailboxId, domainId, address, changes[] -- the names of the changed fields (such as may_send, active, password), never their values
mailbox.deletedA mailbox was deleted (with its original address)mailboxId, domainId, address
alias.created, alias.deletedAn alias was added or removedaliasId, domainId, address, destinations[]
forwarding.created, forwarding.deletedA forwarding rule was added or removedforwardingId, mailboxAddress, destination
webhook.testYou pressed "send test" (panel or API)message, endpointId

Things worth knowing:

What is guaranteed

Be precise about what "durable" means here:

EventGuarantee
email.receivedGuaranteed. 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.sentGuaranteed 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.bouncedNear-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_unknownGuaranteed, 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:

HeaderMeaning
X-Mila-EventThe event type, such as mailbox.created
X-Mila-Event-IdThe event's id (evt_...), shared by all endpoints that get it
X-Mila-Delivery-IdThe id of this delivery. Stable across every retry -- deduplicate on it
X-Mila-Attempt1 for the first attempt, then 2, 3, ... (a manual resend keeps counting)
X-Mila-TimestampUnix seconds when this attempt was signed
X-Mila-Signaturev1=<hex>; two comma-separated values during a secret rotation
X-Mila-RecipientOnly 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-AgentMila-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

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:

StateMeaning
pending, deliveringQueued, or being sent right now
deliveredYou answered 2xx
rejectedYou answered 4xx (except 408/429)
failedFive 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_largeThe body would exceed 1 MiB; see "Receiving mail"
cancelledThe 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) )

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:

Things to know when you turn it on:

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-Results header, with the authserv-id mx1.mila.cx, and the first Authentication-Results header in the message is mila's. Trust only that first one.

mila enforces it for every message:

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.

Security