Verifying webhook signatures
Every delivery carries X-SignEnvoy-Signature, an HMAC over the
exact bytes of the request body using the endpoint's secret (the
sewh_... value shown once when the endpoint was created or
its secret was rotated).
The scheme
X-SignEnvoy-Signature: t=<unix>,v1=hex(hmac_sha256(secret, "{t}.{body}")) tis the Unix time (seconds) at which this delivery attempt was sent. A retry is signed again with a fresht.v1is lowercase hex ofHMAC-SHA256(secret, t + "." + body), wherebodyis the raw request bytes.- The header can carry more than one
v1: for 24 hours after a secret rotation, one is computed with the new secret and one with the previous secret, in that order. A delivery is valid when anyv1matches the secret your handler holds.
X-SignEnvoy-Signature: t=1756000000,v1=<new secret>,v1=<previous secret> Verify
- Read the raw request body as bytes. Do not parse and re-serialize it first: the body is compact JSON with sorted keys, and the signature covers those exact bytes.
- Split the header on
,; taketand everyv1. - Compute
HMAC-SHA256(secret, t + "." + body)and compare it to eachv1with a constant-time comparison. Accept on the first match. - Treat
idin the body as the deduplication key: the same event is redelivered with the sameidand a new signature.
import hashlib, hmac
def verify(secret: str, header: str, body: bytes) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next(v for k, v in parts if k == "t")
expected = hmac.new(secret.encode(), f"{t}.".encode() + body,
hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1") Replays and the timestamp
SignEnvoy does not enforce a maximum age on t; a redelivery
requested from the dashboard or the API days later is signed with the
time it is sent. If your handler rejects deliveries whose t
is older than a window of your choosing, deduplicate on id
as well so a late retry of a delivery you already processed is
harmless. Retries happen after 30 s, 5 min, 30 min,
with a 10-second timeout per attempt.
Rotating the secret
POST /v1/webhooks/{id}/rotate-secret (or "Rotate secret"
in the dashboard) returns a new secret, shown once. For the next
24 hours every delivery to that endpoint is signed with both
the new and the previous secret, so you can switch your handler at any
point in that window without a failed verification. After the window
the previous secret is discarded and the header carries one
v1 again. GET /v1/webhooks shows
previous_secret_expires_at while the window is open.
Rotating again inside the window retires the older secret immediately:
at most two secrets are ever live.