EMX webhooks
A webhook is a URL of yours that hears about an account's events. Make one in the web client under Settings, Webhooks, or with the API:
POST /api/accounts/me/webhooks
Authorization: Bearer emx_…
Content-Type: application/json
{"url": "https://crm.example.ch/emx", "events": ["message.received", "delivery.failed"], "description": "CRM"}
The answer carries the signing secret (whsec_…) once. Twenty webhooks
per account. The URL must be https on a public host; private addresses
are refused when the hook is made and again every time it is sent to.
Events
| Event | When | data |
|---|---|---|
message.received |
a message was filed in the account, other than a Sent copy or a mail app's upload | message: id, threadId, mailboxId, subject, from, to, cc, receivedAt, size, hasAttachments, messageId, sealed |
delivery.failed |
a message the account sent could not be delivered to one recipient | queueMessageId, recipient, status (5.1.1 and the like), reason, remoteServer |
ping |
you pressed Test | message, sentAt |
What a delivery looks like
POST /emx HTTP/1.1
Content-Type: application/json
User-Agent: EMX-Webhooks/1.0
X-EMX-Event: message.received
X-EMX-Delivery: 01a0d3af-44b8-7411-9c92-9ff6c3a4d467
X-EMX-Attempt: 1
X-EMX-Signature: t=1790256990,v1=5f3a…
{
"id": "evt_01a0d3af44b874119c929ff6c3a4d467",
"type": "message.received",
"createdAt": "2026-09-24T13:56:30.123Z",
"account": {"id": "…"},
"data": {"message": {"id": "…", "subject": "Bestellung 4711", "from": {"address": "anna@beispiel-ag.ch", "name": "Anna Beispiel"}, "…": "…"}}
}
The body never contains a message's text; fetch that with the API if you
need it. A sealed account's message.received events leave out the
subject and the addresses, which the seal protects.
Verifying the signature
X-EMX-Signature is t=<unix seconds>,v1=<hex>, where v1 is
HMAC-SHA256 with your secret over <unix seconds>.<raw body>. Check it
before you trust the body, and refuse a timestamp older than five
minutes:
func verify(secret, header string, body []byte) bool {
var ts, v1 string
for _, part := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(part, "="); ok {
switch k {
case "t":
ts = v
case "v1":
v1 = v
}
}
}
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(body)))
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, body) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const want = createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex');
return want.length === parts.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
Use the raw request body as received, not a re-serialised copy.
Retries and failures
Answer any 2xx within 30 seconds; do the work afterwards. Anything else,
or no answer, is tried again after one minute, five, thirty, two hours
and then every twelve, eight times in all. X-EMX-Delivery is the same on
every retry, so you can ignore what you already handled. Redirects are
not followed.
A hook whose deliveries fail for good twenty-five times in a row is
turned off with a reason you see in the settings; turn it on again with
POST /api/webhooks/{id}/enable once your side is fixed.
GET /api/webhooks/{id}/deliveries shows what went out, with status,
attempts and the next try.