Webhooks
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
| Event | Means |
|---|---|
email.queued | accepted and waiting to go out |
email.sent | handed to the receiving mail server |
email.delivered | the receiving server accepted it |
email.bounced | permanently rejected. The address is suppressed. |
email.complained | the recipient marked it as spam. The address is suppressed. |
email.suppressed | not sent, because that address was already suppressed |
email.failed | could 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 freshThree things people get wrong here:
- 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.
- Compare in constant time.
==on a string leaks how much of the signature was right, one character at a time. - 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.