EVENTS
Webhooks and events
Verify a signed delivery, de-duplicate safely, and understand ordering, retries, replays, and attachment links.
What we send
Every event is one HTTP POST of a JSON envelope to each of your active endpoints, one request per endpoint. Five event types exist today; adding a new type is an additive change, so treat an unrecognized type as something to ignore rather than an error.
message.receivedA message arrived for this agent and the agent is allowed to see it. Quarantined mail and mail an approval gate is holding produce no message.received at all.
attachment.scannedOne per attachment on an inbound message, carrying the hash, the measured size, the type our own magic-byte sniff found, and the antivirus verdict.
message.policy_decidedThe decision record for a message: what the policy engine decided and why. Emitted for outbound sends too, including ones rejected before a message row existed.
message.sentAn outbound message was accepted by the delivery provider. Metadata only — ids, the provider message id, a recipient count and a timestamp.
identity.pausedTekMail held this agent's outbound after an automatic anomaly check. Inbound is unaffected, the API key still works, and nothing was deactivated or deleted — what stopped is sending. The payload carries the signal, what was counted and when the hold began. email_id is null: a pause is about an identity, not a message.
The envelope
The envelope is identical for every type; only data differs. email_id is null when a message was rejected before it was ever recorded — an outbound send blocked by policy has a decision to report and no row to look up — and also when the event is not about a message at all, as with identity.paused.
{
"id": "evt_01K7EXAMPLEEVENT",
"type": "message.received",
"sequence": 41,
"occurred_at": "2026-08-06T10:22:49.000Z",
"agent_id": "agt_01K7SUPPORTBOT",
"email_id": "em_01K7INBOUNDMESSAGE",
"data": { }
}Headers
Three sets travel on every request: the Standard Webhooks trio you verify against, TekMail extensions, and the original header set kept byte-identical for consumers written before this.
| webhook-id | The delivery id (whd_…). Stable across every retry of one delivery, and NEW for a replay. This is your idempotency key. |
|---|---|
| webhook-timestamp | Unix seconds, minted fresh for each attempt — not carried over from the first one. |
| webhook-signature | One space-delimited entry per active signing secret, each v1, followed by a base64 HMAC-SHA256. Current secret first. |
| x-tekmail-event-id | The event id (evt_…). Stable across every delivery of this event AND across every replay of it. |
| x-tekmail-attempt | 1-based attempt counter for this delivery. |
| x-tekmail-replay | true when this delivery exists because someone asked for a replay. |
| x-tekmail-sequence | The per-agent ordering cursor. Omitted entirely rather than sent as the string null. |
| X-TekMail-Event / -Delivery / -Timestamp / -Signature | The original TekMail header set, unchanged. X-TekMail-Signature is v1= followed by a hex HMAC-SHA256 over the timestamp, a dot and the body, and is single-valued: during a rotation it carries the current secret only. |
webhook-id: whd_01K7EXAMPLEDELIVERY
webhook-timestamp: 1786000000
webhook-signature: v1,QBO8FfPxZTxNTmipWKFTv8D3iEBlPG8Y7g95BG+vsPc=
x-tekmail-event-id: evt_01K7EXAMPLEEVENT
x-tekmail-attempt: 1
x-tekmail-replay: false
x-tekmail-sequence: 41
X-TekMail-Event: message.received
X-TekMail-Delivery: email:em_01K7INBOUNDMESSAGE
X-TekMail-Timestamp: 1786000000
X-TekMail-Signature: v1=885617d0d12b7225fbed6aae099c5dd7b30619bb82ff050f39288e0c2bf98353Verify a delivery — Node.js
No dependencies, and it runs as written. Read the raw request body before any JSON body parser touches it — in Express that means express.raw({ type: "application/json" }) on this route.
// verify-tekmail-webhook.js — Node 18+, no dependencies. Run: node verify-tekmail-webhook.js
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300; // 5 minutes, matching the replay window below.
function verifyTekMailWebhook(secret, headers, rawBody) {
// rawBody is the EXACT bytes we POSTed. Never re-serialize: JSON.stringify of
// a parsed body reorders keys and drops whitespace, and the signature will
// never match. This is the single most common verification failure.
const webhookId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!webhookId || !timestamp || !signatureHeader) return false;
// 1. Reject a stale or future-dated stamp before spending any CPU on crypto.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
// 2. The secret is "whsec_" + standard base64. Decode the part AFTER the
// prefix to raw key bytes; signing the printable form gives a different
// digest and nothing will ever verify.
const key = Buffer.from(secret.slice("whsec_".length), "base64");
// 3. HMAC-SHA256 over id + "." + timestamp + "." + raw body, base64-encoded.
const signedContent = webhookId + "." + timestamp + "." + rawBody;
const expected =
"v1," + crypto.createHmac("sha256", key).update(signedContent).digest("base64");
const expectedBytes = Buffer.from(expected);
// 4. The header carries one space-delimited entry per ACTIVE secret — there
// are two during a rotation overlap — so compare against each in constant
// time. The length check is required because timingSafeEqual throws on a
// length mismatch; it compares sizes, never signature bytes.
for (const candidate of signatureHeader.split(" ")) {
const candidateBytes = Buffer.from(candidate);
if (
candidateBytes.length === expectedBytes.length &&
crypto.timingSafeEqual(candidateBytes, expectedBytes)
) {
return true;
}
}
return false;
}
// ── A self-contained check you can run right now ────────────────────────────
// This block signs a sample delivery and verifies it: it prints "true", then
// "false" for a tampered body. The secret below is a PLACEHOLDER, not a key —
// its base64 body decodes to the words "replace-this-with-your-own-secret".
// Swap in the real one, which is shown once when you create or rotate an
// endpoint, and keep it out of source control.
const secret = "whsec_cmVwbGFjZS10aGlzLXdpdGgteW91ci1vd24tc2VjcmV0";
const rawBody = '{"id":"evt_example","type":"message.received","sequence":41}';
const sampleHeaders = {
"webhook-id": "whd_example",
"webhook-timestamp": Math.floor(Date.now() / 1000).toString(),
};
sampleHeaders["webhook-signature"] =
"v1," +
crypto
.createHmac("sha256", Buffer.from(secret.slice("whsec_".length), "base64"))
.update(
sampleHeaders["webhook-id"] + "." + sampleHeaders["webhook-timestamp"] + "." + rawBody
)
.digest("base64");
console.log(verifyTekMailWebhook(secret, sampleHeaders, rawBody)); // true
console.log(verifyTekMailWebhook(secret, sampleHeaders, rawBody + " ")); // falseVerify a delivery — Python
The same algorithm, standard library only.
# verify_tekmail_webhook.py — Python 3.8+, standard library only.
# Run: python3 verify_tekmail_webhook.py
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300 # 5 minutes, matching the replay window below.
def verify_tekmail_webhook(secret, headers, raw_body):
# raw_body is the EXACT bytes we POSTed (request.get_data() in Flask,
# await request.body() in FastAPI). Never rebuild it from the parsed JSON:
# re-serializing reorders keys and drops whitespace, and the signature will
# never match. This is the single most common verification failure.
webhook_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not webhook_id or not timestamp or not signature_header:
return False
# 1. Reject a stale or future-dated stamp before spending any CPU on crypto.
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
# 2. The secret is "whsec_" + standard base64. Decode the part AFTER the
# prefix to raw key bytes; signing the printable form gives a different
# digest and nothing will ever verify.
key = base64.b64decode(secret[len("whsec_"):])
# 3. HMAC-SHA256 over id + "." + timestamp + "." + raw body, base64-encoded.
signed_content = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
digest = hmac.new(key, signed_content, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
# 4. The header carries one space-delimited entry per ACTIVE secret — there
# are two during a rotation overlap — so compare against each in constant
# time with compare_digest, never with ==.
for candidate in signature_header.split(" "):
if hmac.compare_digest(candidate, expected):
return True
return False
# ── A self-contained check you can run right now ────────────────────────────
# This block signs a sample delivery and verifies it: it prints "True", then
# "False" for a tampered body. The secret below is a PLACEHOLDER, not a key —
# its base64 body decodes to the words "replace-this-with-your-own-secret".
# Swap in the real one, which is shown once when you create or rotate an
# endpoint, and keep it out of source control.
if __name__ == "__main__":
secret = "whsec_cmVwbGFjZS10aGlzLXdpdGgteW91ci1vd24tc2VjcmV0"
raw_body = b'{"id":"evt_example","type":"message.received","sequence":41}'
sample_headers = {
"webhook-id": "whd_example",
"webhook-timestamp": str(int(time.time())),
}
sample_key = base64.b64decode(secret[len("whsec_"):])
sample_content = (
sample_headers["webhook-id"].encode()
+ b"."
+ sample_headers["webhook-timestamp"].encode()
+ b"."
+ raw_body
)
sample_headers["webhook-signature"] = "v1," + base64.b64encode(
hmac.new(sample_key, sample_content, hashlib.sha256).digest()
).decode()
print(verify_tekmail_webhook(secret, sample_headers, raw_body)) # True
print(verify_tekmail_webhook(secret, sample_headers, raw_body + b" ")) # FalseReplay window
Reject any delivery whose webhook-timestamp is more than 300 seconds — 5 minutes — from your own clock, in either direction. That window can be this tight because every attempt is signed fresh: a retry eight minutes after the first attempt carries a stamp minted at the moment it was sent, so it still arrives well inside the window. Nothing ever re-sends an old timestamp, so widening the tolerance buys you nothing and costs you replay protection.
Idempotency
Use webhook-id as your idempotency key. It is stable across every retry of one delivery and new for a deliberate replay, so retries de-duplicate on their own while a replay you asked for still runs.
If you would rather a replay be a no-op as well, key on x-tekmail-event-id instead: that value is stable across replays too. Pick one and be consistent — keying on both means the replay button does nothing and you will not know why.
Ordering
Events for the same message carry a strictly increasing `sequence` and are emitted in lifecycle order: message.received -> attachment.scanned (zero or more) -> message.policy_decided. Delivery order is not guaranteed: a failed delivery retries independently, so you may receive message.policy_decided before attachment.scanned. Order by `sequence` and treat out-of-order arrival as normal, not as an error.
Retries and dead letters
A 2xx is a delivery. Anything else is a failure, including a 3xx: we send with redirects refused, so a redirect is recorded as a failed attempt rather than followed. A 4xx other than 408, 425 and 429 is terminal — we do not retry a 401 or a 422, because every attempt would get the same answer.
A retryable failure is retried five times on a jittered 30 / 60 / 120 / 240 / 480-second backoff. After the last one the delivery moves to an inspectable dead-letter state with every attempt recorded: status, latency, and a redacted excerpt of the response your endpoint returned. Open Webhooks → Delivery history in the dashboard to read them and to replay, or use the replay endpoints in the API. An endpoint that dead-letters repeatedly is disabled automatically, and the dashboard says so in a sentence rather than going quiet.
Rotating your secret
Rotation is an overlap, not a cutover. For the length of the overlap window both secrets sign every delivery, and webhook-signature carries one entry per secret, current first. That is why the snippets above loop over the space-delimited entries instead of reading the first one: try each until one matches.
Deploy the new secret, confirm deliveries are verifying, then retire the old one from the dashboard. If you never loop, rotation looks like an outage.
Attachments
A payload never carries bytes. Each attachment arrives as metadata — filename, the sender-declared content type next to the one we detected ourselves, the measured size, a SHA-256 and the scan status — plus a short-lived signed download URL when the bytes may be offered. Held and quarantined mail carries the metadata with no URL at all and a stated reason.
The URL expires in 15 minutes, and a download is re-checked against the agent's current attachment policy and the message's current state at the moment you fetch it. Both matter: a URL you pulled out of an old dead-letter payload may be refused even inside its lifetime, because turning attachment downloads off takes effect on the next request rather than whenever the last link happens to expire. That refusal is correct, not a bug. Mint a fresh URL with GET /api/v1/attachments/:id/download.