Webhooks pro

mimux POSTs a small signed JSON body to your URL when something happens, so nothing has to poll.

Setting one up

Endpoints are managed in Settings → Webhooks, or over the REST API with the webhooks:manage scope:

curl -X POST "$BASE/api/v1/webhooks" \
  -H "Authorization: Bearer $MIMUX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/mimux","events":["message.received"]}'

The signing secret is in that response and in no other until you rotate it. Store it now.

url must be an absolute http/https URL; anything else is a 400, and the delivery engine checks again on the line that actually dials out. Unknown event names are dropped silently, so check the events you get back. An endpoint subscribed to nothing is legal and simply never fires — a test delivery still works.

Events

EventFires whenPayload
message.receivedA new message lands in an account's inbox.id, account, folder, folder_id, from, subject, date, snippet, message_id
message.sentA message lands in an account's Sent folder.id, account, to, subject, date, message_id
message.updatedA message mimux already had was read, starred, labelled or moved — by you, by the API, by a filter rule, or by another mail client touching the same mailbox.the message.received fields, as they are after the change, plus change (read, unread, starred, unstarred, labeled, unlabeled, moved) and origin (mimux or external)
message.deletedA message mimux had is no longer on the server in that folder — deleted in another mail client, or moved out of it.the message.received fields, read off the row before it was dropped
sync.errorAn account goes into the error state, or changes its reason for being there.account, error
search.completedA deep search job finishes — the one API call whose answer arrives long after its response was written.job_id, query, results (a count; fetch the messages with GET /v1/search/jobs/{id})
pingOnly from the test endpoint. Not subscribable, which is why it is not in events.

sync.error fires on the edge into the error state, not on every sync-status broadcast while it stays broken — a provider that is down for an hour is one event, not seven hundred.

What message.updated can and cannot see

origin: "mimux" is exact: every change mimux makes fires one, because mimux is the one making it. origin: "external" — a flag another mail client flipped — is found by diffing what the server reports against what is already stored, and that diff has limits worth knowing before you build on it:

  • Read/unread and star/unstar only, whoever made the change. Labels are add-only: mimux merges the keywords the server reports into what it already has, so a label another client removed is invisible.
  • CONDSTORE servers only. The diff rides the same CHANGEDSINCE fetch the flag sync uses. A server without CONDSTORE gets no external-change events at all.
  • The folders that account syncs continuously. The inbox, Sent and Drafts by default; add more per account in Settings → Syncing. Anything outside that set is only re-read when the connection is re-established, so a star applied in Archive is noticed on the next reconnect, not within the minute.
  • Bursts are dropped. The first sync after an outage can carry hundreds of changes at once. That is a catch-up, not live activity, so past Settings → Webhooks → External change burst limit (200 by default; 0 turns the cap off) the changes are stored and not delivered.
  • Nothing until the account has completed one sync in this process — otherwise a first sync would replay the whole mailbox one delivery at a time.

What message.deleted means

It means gone from that folder, which is all IMAP can tell us. A message another client moved from the inbox to Archive is, from the inbox's point of view, indistinguishable from one it deleted: mimux drops the row and fires message.deleted, and the same message later re-arrives as message.received when its new folder is synced. Deduplicate on message_id if that pair matters to you.

The payload is built from the row before it is dropped — after that there is nothing left to describe. Same reconciliation rules as external changes above: the inbox every cycle, other folders on reconnect, nothing at all during a folder's first sync, and a batch over the burst limit is applied to the store without being delivered.

A move performed by a filter rule is the one mimux-side change that may not fire: the rule runs during the sync, and on a server without UIDPLUS the local row is dropped rather than relocated, so there is nothing left to describe. The message reappears as message.received when its new folder is synced.

No message bodies, ever. message.received carries a summary and a snippet. Bodies are large, and a webhook is the one place mail leaves this machine without the user watching. Fetch the body with the API if you need it.

Which is a fetch the payload already tells you how to make: data.id is the mimux message id, so a receiver reads whatever it needs over an authenticated, scoped call it makes itself — the content never rides on the delivery.

GET /v1/messages/{id}?body=text        # the body
GET /v1/messages/{id}?headers=both     # the raw header block and its parsed form

Inside a webhooks listen -execute script the same two lines are mimux mail read $ID and mimux mail read -headers both $ID: the child process inherits MIMUX_URL from the listener, so it talks to the instance you are listening to.

The delivery

POST /your/url
Content-Type: application/json
User-Agent: mimux-webhooks/1
X-Mimux-Event: message.received
X-Mimux-Delivery-Id: 9f2c4a1e8b7d3056...
X-Mimux-Signature: t=1755500000,v1=3a7f...

{
  "id": "9f2c4a1e8b7d3056...",
  "event": "message.received",
  "created_at": "2026-08-18T09:14:02Z",
  "data": { ... }
}

The envelope is always those four fields; only data varies by event. X-Mimux-Delivery-Id is stable across retries and replays — deduplicate on it.

Verifying the signature

X-Mimux-Signature is t=<unix>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<t>.<body>"). The timestamp is inside the signed string, so a receiver that rejects an old t cannot be fooled by re-stamping a captured delivery.

import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts["t"], parts["v1"]
    if abs(time.time() - int(t)) > tolerance:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, v1)

Sign the raw request body, byte for byte — not a re-serialised parse of it. mimux renders the body once when the delivery is queued and stores it verbatim; every attempt sends exactly those bytes, so a retry verifies the same way the first try did. Only the signature's timestamp differs between attempts, because it is computed per attempt.

Retries

Reply 2xx and the delivery is done. Anything else walks a seven-step ladder, front-loaded because most failures are a receiver restarting and most of the rest are a receiver that is down for the day:

Attempt1234567
Delay before it0s1m5m30m2h8h14h

Seven attempts spread over roughly 24.5 hours. Two rules sit outside the ladder:

  • 410 Gone stops immediately. It is the standard "this subscription is over" answer, and mimux honours it as one: the delivery goes straight to dead with no further attempts.
  • Everything else retries — timeouts, 5xx, 429, and the other 4xx too, because a misconfigured receiver is usually misconfigured temporarily.

Pause and resume

POST /v1/webhooks/{id}/pause
POST /v1/webhooks/{id}/resume

pause sets active: false without touching url or events — the narrow version of PATCH for "stop sending to this one for a while". Queued deliveries wait rather than being dropped, and go out when the endpoint is resumed. resume sets active: true and clears auto_disabled_at, whether the endpoint was paused by hand or auto-disabled by the engine.

Auto-disable

When a delivery exhausts the whole ladder, it is marked dead and the endpoint is switched off. A receiver that ignored us for a day is not coming back on its own, and the alternative — keep queueing into a black hole — buries the next real event under a hundred dead rows.

An auto-disabled endpoint carries auto_disabled_at. Fix the receiver, then PATCH it back on, which clears the field:

curl -X PATCH "$BASE/api/v1/webhooks/7" \
  -H "Authorization: Bearer $MIMUX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"active":true}'

A paused or auto-disabled endpoint keeps its queue: pending rows wait rather than being dropped, and go out when it is switched back on.

Transport limits

Request timeout10 seconds, end to end
Redirects followed2 hops — enough for http→https and a path move; longer is a misconfigured receiver
Response bodyThe first 2KB of your reply are kept against the delivery row (response_body); the rest is discarded.
Drain interval5 seconds for retries and leftovers; a fresh delivery is sent immediately when queued

Local development

Writing a receiver against a URL that has to be publicly reachable is the worst part of webhooks. It doesn't have to be: a pro build's CLI streams live events straight to a port on your laptop.

mimux mail webhooks listen -forward-to http://localhost:3000/hooks/mimux
listening for: every event — forwarding to http://localhost:3000/hooks/mimux
signing secret: whsec_kR8vP2mQ...
Verify it exactly as you verify production: HMAC-SHA256 over "<t>.<body>".
Nothing is queued while this is not connected — events that fire meanwhile are lost.
Ctrl-C to stop.

message.received   200      14ms
message.updated    200       9ms

Put that whsec_… secret in your receiver's config and verify with the same code you wrote for production — the check is identical. Each forwarded request carries the same three headers (X-Mimux-Event, X-Mimux-Delivery-Id, X-Mimux-Signature) over the same body bytes, signed the same way. A receiver that verifies here verifies unchanged when a real endpoint points at it.

Nothing is registered and nothing is stored: a listener sees every event, whatever your configured endpoints are subscribed to. That is what makes it zero-config — it behaves like a new webhook subscribed to everything, for as long as it runs. Narrow it with -events:

mimux mail webhooks listen -forward-to http://localhost:3000/hooks \
  -events message.received,message.updated

Events that fire while nothing is listening are lost. They are not queued, not buffered and not replayable to a listener — the stream is live or it is nothing. Replay exists only for configured endpoints, whose deliveries are rows in a table. If an event matters, it needs a real endpoint.

The same applies while a listener is connected but not keeping up: the server buffers a little and then drops, rather than letting a stalled receiver hold up mail syncing. Dropped events are reported on the next line that does get through, so you always know it happened.

Instead of a URL, -execute runs a command once per event — no receiver to write at all:

mimux mail webhooks listen -events message.received -execute ./notify.sh

The payload arrives on the command's stdin — never as an argument — with MIMUX_EVENT, MIMUX_DELIVERY_ID and MIMUX_URL (the instance being listened to, so a mimux mail read in the script reaches it) in its environment. The command runs through sh -c, so -execute 'jq .' and pipes work; event data still only ever reaches it on stdin, never interpolated into the command line. Its stdout and stderr flow straight to yours. Runs are serial, one event at a time, and each is killed after 60 seconds — a slow script shows up as the dropped counter, not as a wedged stream. No signing secret is minted in this mode: HMAC proves who sent an HTTP request, and a process the command spawns itself has nothing to prove. Everything else is the same — nothing is queued while it is not connected, and a non-zero exit is printed with its status and elapsed time, never retried.

Forwards are not retried. There is no ladder and no backoff here: a non-2xx from your receiver is printed with its status and how long it took, and the next event follows. A receiver you are watching does not need a day of retries, and a silent retry would hide the failure you are looking for. The stream itself does reconnect — after three seconds — when it drops, so a mimux restart on the other end is a blip rather than the end of the session. A rejected token, a missing webhooks:manage scope or a lapsed licence stops it instead of retrying forever.

Under the hood it is GET /v1/webhooks/listen, an NDJSON stream — one JSON object per line, a {"type":"ping"} keepalive every 30 seconds — so anything that can read a line at a time can consume it directly.

Stats

GET /v1/webhooks returns each endpoint with a stats object folded from its kept delivery log: total, success_rate (percentage of settled deliveries — delivered or dead — that were delivered; absent until something has settled, rather than lying with a 0%), failing (retrying plus dead), pending, and last_delivery_at / last_status / last_status_code for the most recently queued delivery. The same object comes back from every other call that returns a webhook — create, PATCH, pause/resume, secret rotation.

The delivery log

GET /v1/webhooks/{id}/deliveries

The endpoint's deliveries, newest first, each with its delivery_id, event, status (pending / failed / ok / dead), attempt count, last HTTP status, last transport error, the receiver's answer (response_body, capped at 2KB) and how long the last attempt took (duration_ms), and when the next attempt is due. The request payload itself is not included — read it from your own receiver, or replay the delivery.

Filter with ?status= and/or ?event=, and page with ?limit= (1-100, default 25) and ?cursor= (from a previous response's next_cursor) — the same filters and pager the deliveries screen in Settings uses. The underlying log is bounded at the write end regardless of any filter: the last 100 deliveries per endpoint are kept, older rows pruned as new ones arrive.

Replay

POST /v1/webhooks/{id}/deliveries/{delivery_row_id}/replay

Re-queues a delivery for immediate sending with a fresh retry budget. The body and the delivery_id are unchanged, so a receiver that already processed it recognises the duplicate. Use it after fixing a receiver that was returning 500, or to re-deliver something you lost. Sending happens in the background; the call returns as soon as the row is queued.

Test

POST /v1/webhooks/{id}/test

Queues a ping and sends it immediately — the way to prove the URL, your signature check and your parser all work without waiting for real mail. ping ignores the subscription list on purpose, so an endpoint subscribed to nothing still receives its test. It goes through the normal machinery, which means a failing ping walks the same ladder and can auto-disable the endpoint like any other delivery.

Rotating the secret

curl -X POST "$BASE/api/v1/webhooks/7/secret" \
  -H "Authorization: Bearer $MIMUX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Replaces the endpoint's signing secret. An empty body (or secret omitted) generates one; supply your own and it is used as given, as long as it is at least 16 characters — the same rule the Settings UI enforces on a hand-typed secret. Either way the response is shaped like the create response (webhook, secret, note), and it is the only place the new secret is shown. Every attempt made after this — including a replay of an older delivery — signs with the new secret, so update the receiver at the same time.

Free builds have the endpoint table and the Settings UI, and nothing that posts. The delivery engine lives in the pro layer; without it, endpoints can be configured but never fire.

What gets logged

Which endpoint, which event, and how it went. Never the payload, never the secret, never the signature.