Elchi Docs

↑ ↓ to move · Enter to open · Esc to close

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.

3 min read