Skip to content

Webhooks

Webhooks push delivery events to your server as they happen — deliveries, bounces, complaints, opens, clicks, and more. Register an endpoint, then verify the signature on every incoming request.

Call POST /v1/webhook-endpoints with the URL to receive events and the events to subscribe to:

Terminal window
curl https://mail.teamander.com/v1/webhook-endpoints \
-H "Authorization: Bearer $TEAMANDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/teamander",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'

The response includes a signing secret (whsec_...) shown once — store it to verify signatures. A tenant may register up to 10 endpoints.

Event Meaning
email.sent Accepted by the receiving SMTP server
email.delivered Confirmed delivered to the inbox provider
email.deferred Temporarily deferred; delivery will be retried
email.bounced Permanently rejected
email.complained Marked as spam by the recipient
email.failed Gave up after all retry attempts
email.opened Recipient opened the email
email.clicked Recipient clicked a tracked link
email.unsubscribed Recipient unsubscribed

Each request body is a JSON event. Deduplicate on id:

{
"id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "email.bounced",
"created": 1753900000,
"data": {
"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"recipient": "jane@example.com",
"status": "bounced",
"bounce_code": "5.1.1",
"bounce_message": "mailbox does not exist"
}
}

Every request carries an X-Teamander-Signature header:

X-Teamander-Signature: t=1753900000,v1=3d5f...9ab

v1 is HMAC-SHA256(secret, "<t>.<rawBody>"). Compute the same HMAC over the timestamp, a literal ., and the raw request body, then compare with a constant-time check. Reject requests whose timestamp is too old to prevent replays.

import crypto from "node:crypto";
// `rawBody` must be the exact bytes received (verify before JSON parsing).
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}