All posts

How to receive email with webhooks

Register an endpoint, check the signature on every delivery, and know what ACK does when your server is down.

ACK can tell your app about new mail with an HTTP POST, so you don't have to poll the inbox. This post covers registering an endpoint, checking that a request really came from ACK, and what happens when your endpoint doesn't answer.

Register an endpoint

A webhook can cover the whole organisation, one pod or one inbox. This one covers every inbox in the organisation and only fires for new mail.

ShellHTTP
curl https://api.ack.now/v0/webhooks \
  -H "Authorization: Bearer $ACK_API_KEY" \
  --json '{"url": "https://example.com/hooks/ack",
           "event_types": ["message.received"]}'

# {"webhook_id": "whk_…", "secret": "whsec_…", …}
POST /v0/webhooks
{"url": "https://example.com/hooks/ack",
 "event_types": ["message.received"]}

For a single inbox, send the same request to /v0/inboxes/{inbox_id}/webhooks.

The URL has to be https and resolve to a public address. ACK checks this when you create the webhook and again before every delivery, and it doesn't follow redirects. Keep the secret from the response. You need it to check signatures, and you can get it again with GET /v0/webhooks/{webhook_id}.

What a delivery looks like

HTTP
POST /hooks/ack
content-type: application/json
webhook-id: evt_01k8…
webhook-timestamp: 1792750440
webhook-signature: v1,K5o3tD…=

{
  "type": "event",
  "event_type": "message.received",
  "event_id": "evt_01k8…",
  "message": {
    "inbox_id": "support-agent@trial.ack.now",
    "message_id": "<CAH7f2…@mail.northwind.co>",
    "from": "Dana Ruiz <dana@northwind.co>",
    "subject": "Invoice 4471 charged twice",
    "text": "Hi, I see two charges for invoice 4471…",
    …
  },
  "thread": { … }
}
webhook-id: evt_01k8…
webhook-timestamp: 1792750440
webhook-signature: v1,K5o3tD…=

{"type": "event",
 "event_type": "message.received",
 "message": {…},
 "thread": {…}}

The webhook-id header is the event ID. A delivery can arrive more than once, so store the ID and skip events you've already handled.

New mail that fails a check gets its own event type, so an endpoint subscribed only to message.received never sees it:

  • message.received.spam: it failed spam checks.
  • message.received.unauthenticated: it failed DMARC, or failed both SPF and DKIM. Forged senders usually end up here.
  • message.received.blocked: the sender is on the inbox's block list, or the inbox has an allow list and the sender isn't on it.

The other events follow mail you send (message.sent, message.delivered, message.bounced, message.complained and message.rejected), plus message.opened for the first open when tracking is on, and domain.verified. Mail you import doesn't fire any of them, so an agent won't answer last year's email.

Check the signature

ACK signs every delivery the Standard Webhooks way. To check one:

  1. Drop the whsec_ prefix from your secret and base64-decode the rest. That's the key.
  2. Join the webhook-id, the webhook-timestamp and the raw body with dots.
  3. Compute an HMAC-SHA256 of that string with the key, and base64-encode it.
  4. Compare it with the part after v1, in webhook-signature, in constant time.

In Node, with no dependencies:

verify.js
import crypto from 'node:crypto';

const key = Buffer.from(process.env.ACK_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');

export function verify(headers, rawBody) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
  return headers['webhook-signature'].split(' ').some((sig) => {
    const [version, value = ''] = sig.split(',');
    return version === 'v1' && value.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}

In Python:

verify.py
import base64, hashlib, hmac, os, time

KEY = base64.b64decode(os.environ["ACK_WEBHOOK_SECRET"].removeprefix("whsec_"))

def verify(headers, raw_body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
    return any(
        version == "v1" and hmac.compare_digest(value, expected)
        for version, _, value in (s.partition(",") for s in headers["webhook-signature"].split())
    )

Use the raw body exactly as it arrived. If your framework parses the JSON first, serialising it again can change the bytes, and the signature won't match. The timestamp check stops someone replaying a captured request later. Both examples allow five minutes.

Any Standard Webhooks library works too. ACK also sends the same values as svix-id, svix-timestamp and svix-signature, for libraries that look for those names.

If your endpoint is down

ACK waits up to 10 seconds for a 2xx response. Anything else counts as a failure, and ACK tries again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours. It gives up on a delivery after 24 hours.

If an endpoint fails for 24 hours straight, ACK turns it off. Fix whatever broke, then turn it back on:

ShellHTTP
curl -X PATCH https://api.ack.now/v0/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $ACK_API_KEY" \
  --json '{"enabled": true}'
PATCH /v0/webhooks/{webhook_id}
{"enabled": true}