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.
Register an endpoint
Section titled “Register an endpoint”Call POST /v1/webhook-endpoints
with the URL to receive events and the events to subscribe to:
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 types
Section titled “Event types”| 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 |
Payload
Section titled “Payload”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" }}Verifying signatures
Section titled “Verifying signatures”Every request carries an X-Teamander-Signature header:
X-Teamander-Signature: t=1753900000,v1=3d5f...9abv1 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);}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(kv.split("=", 1) for kv in header.split(",")) t = int(parts.get("t", 0)) if not t or abs(time.time() - t) > tolerance: return False expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))Managing endpoints
Section titled “Managing endpoints”GET /v1/webhook-endpoints— list (secret never returned)PATCH /v1/webhook-endpoints/{id}— change URL/events or toggleactiveDELETE /v1/webhook-endpoints/{id}— delete