Webhook signatures
Verify that a webhook delivery really came from EventSend using HMAC-SHA256, with snippets for Node.js, Python, and Elixir.
Every delivery to a webhook destination is signed, so your endpoint can prove the request came from EventSend and not from someone who guessed your URL. Verifying is a few lines of code and you should do it before trusting anything in the body.
What we send
POST https://your-app.example.com/your-endpoint
Content-Type: application/json
X-EventSend-Signature: sha256=<hex digest>
X-EventSend-Timestamp: 1734531200
X-EventSend-Timestamp is the Unix time in seconds when the delivery was signed.
X-EventSend-Signature is sha256= followed by a lowercase hex HMAC-SHA256 digest.
The body is the event:
{
"id": "evt_AZO48DwqfB6fWitt1Mjhpw",
"event": "user-signup",
"level": "success",
"payload": {"user_id": 123, "plan": "free"},
"timestamp": "2026-07-17T12:33:20.000000Z"
}
Unlike chat destinations, webhook deliveries are never truncated — this is your protocol, so you get the whole event.
id is the event's stable public identifier: an opaque, prefixed string (evt_…). Treat it as a
string — compare it, store it, log it, but never parse or derive anything from its contents.
How the signature is computed
The signed message is the timestamp, a literal ., and the raw request body:
<X-EventSend-Timestamp> + "." + <raw body bytes>
That is HMAC-SHA256'd with your organization's signing secret, which you'll find (and can rotate) in Organization settings.
Two things matter for getting this right:
- Use the raw body, not a re-serialized object. Parsing JSON and dumping it again changes key order and whitespace, and the signature will never match. Capture the bytes before your framework parses them.
- Compare in constant time, and reject stale timestamps. Checking that the timestamp is within a few minutes of now stops an attacker from replaying a delivery they captured earlier.
To check a single delivery by hand, paste its body, headers and your secret into the webhook signature checker. It runs in your browser, so nothing you paste leaves the page.
Verify it
Node.js
import crypto from "node:crypto";
// Express: app.use("/your-endpoint", express.raw({ type: "application/json" }))
export function verify(rawBody, headers, secret) {
const timestamp = headers["x-eventsend-timestamp"];
const signature = headers["x-eventsend-signature"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-EventSend-Timestamp", "")
signature = headers.get("X-EventSend-Signature", "")
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
message = timestamp.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={digest}", signature)
Elixir
def verify(raw_body, headers, secret) do
timestamp = Map.get(headers, "x-eventsend-timestamp", "")
signature = Map.get(headers, "x-eventsend-signature", "")
with {unix, ""} <- Integer.parse(timestamp),
true <- abs(System.system_time(:second) - unix) <= 300 do
digest =
:hmac
|> :crypto.mac(:sha256, secret, "#{timestamp}.#{raw_body}")
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare("sha256=" <> digest, signature)
else
_invalid -> false
end
end
Endpoint requirements
Webhook destination URLs must be HTTPS and must resolve to a public address — EventSend refuses to
deliver to private or reserved IP ranges. To test against a local server, use a tunnel such as
ngrok or cloudflared.
Respond with any 2xx as soon as you've verified and queued the event. 5xx responses, 429s
and network failures are retried on a fixed schedule — about fifteen minutes across five attempts
by default — and every attempt is recorded in the delivery log. Because a timed-out request may
have reached you, a retry can occasionally be a second copy: treat the body's id as the
idempotency key. The full contract, including which responses are not retried, is on
Delivery and retries.