Documentation menu

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",
"message": "New user signed up: [email protected]",
"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.