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
| Event | Fires when | Payload |
|---|---|---|
message.received | A new message lands in an account's inbox. | id, account, folder, folder_id, from, subject, date, snippet, message_id |
message.sent | A message lands in an account's Sent folder. | id, account, to, subject, date, message_id |
message.updated | A 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.deleted | A 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.error | An account goes into the error state, or changes its reason for being there. | account, error |
search.completed | A 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}) |
ping | Only 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
CHANGEDSINCEfetch 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:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Delay before it | 0s | 1m | 5m | 30m | 2h | 8h | 14h |
Seven attempts spread over roughly 24.5 hours. Two rules sit outside the ladder:
410 Gonestops immediately. It is the standard "this subscription is over" answer, and mimux honours it as one: the delivery goes straight todeadwith 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 timeout | 10 seconds, end to end |
|---|---|
| Redirects followed | 2 hops — enough for http→https and a path move; longer is a misconfigured receiver |
| Response body | The first 2KB of your reply are kept against the delivery row (response_body); the rest is discarded. |
| Drain interval | 5 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.