<!-- https://docs.elchi.dev/emx-webhooks | EMX | Elchi Docs -->

# 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:

```http
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

```http
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:

```go
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))
}
```

```js
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.
