Webhooks

Delivery events, and how to verify they came from us.

A webhook tells your application what happened to an email after you sent it. Register an HTTPS endpoint in your workspace under Webhooks and we POST a signed JSON event as each one happens.

Events

EventMeans
email.queuedaccepted and waiting to go out
email.senthanded to the receiving mail server
email.deliveredthe receiving server accepted it
email.bouncedpermanently rejected. The address is suppressed.
email.complainedthe recipient marked it as spam. The address is suppressed.
email.suppressednot sent, because that address was already suppressed
email.failedcould not be sent

sent is not delivered. sent means the next server took it; delivered means it accepted responsibility for it. Most mail goes from one to the other in seconds, and a bounce can still arrive afterwards.

The payload

{
  "type": "email.delivered",
  "at": 1756800004000,
  "data": {
    "messageId": "…",
    "from": "receipts@yourcompany.com.au",
    "to": "customer@example.com",
    "subject": "Your receipt",
    "category": "transactional",
    "status": "delivered",
    "detail": null
  }
}

Each request also carries X-Hamani-Event with the event type, so you can route without parsing the body first.

Verifying the signature

Verify every webhook before you act on it. The URL is not a secret; the signature is what proves the request came from us.

Each request carries:

X-Hamani-Signature: t=<unix-ms>,v1=<hex>

Compute HMAC-SHA256 over t + "." + rawBody using the signing secret shown once when you added the endpoint, compare it to v1 in constant time, and reject a t more than five minutes old.

const crypto = require("crypto");

function verify(sigHeader, rawBody, secret) {
  const parts = Object.fromEntries(sigHeader.split(",").map((p) => p.split("=")));
  const mac = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(mac, "hex");
  const b = Buffer.from(parts.v1, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b) && Math.abs(Date.now() - Number(parts.t)) < 300000;
}
import hmac, hashlib, time

def verify(sig_header, raw_body, secret):
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    mac = hmac.new(secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    fresh = abs(time.time() * 1000 - int(parts["t"])) < 300_000
    return hmac.compare_digest(mac, parts["v1"]) and fresh

Three things people get wrong here:

  1. Hash the raw body, not the parsed object. Re-serialising JSON changes whitespace and key order, and the signature will never match. Capture the body as a string or buffer before your framework parses it.
  2. Compare in constant time. == on a string leaks how much of the signature was right, one character at a time.
  3. Check the timestamp. Without it, a valid old request can be replayed at you forever.

Delivery is best-effort

If your endpoint is down, or takes more than 5 seconds to answer, we log it and move on. There is no retry queue.

That is a deliberate trade, and it has one consequence you must design for: a webhook can be missed. Your email log is the record of what happened, not the webhook stream. If a missed event would break something — an order never marked as notified — reconcile against the log rather than trusting that every webhook arrived.

Answer with any 2xx immediately and do the work afterwards. A webhook handler that writes to a queue and returns is correct; one that sends its own email before answering will time out.

Ordering

Events are sent as they happen, not in a guaranteed order. Under normal conditions queued arrives before delivered, but do not assume it. Use the at timestamp on each event and ignore one that is older than the state you already have.