Receiving signed webhooks
GuideDevelopersSince version 1.5.0
On this page
The gateway delivers your tenant's events to your URL, signed in the Standard Webhooks format. Verify the signature before anything else.
Product side: Asynchronous plane and brokers; operations side: Publishing and consuming on a broker.
What you receive
| Element | Value |
|---|---|
| Method and body | POST, a JSON CloudEvent (Content-Type: application/cloudevents+json) |
webhook-id |
The event's id (the CloudEvent's id), stable across attempts |
webhook-timestamp |
Unix seconds of the attempt |
webhook-signature |
v1,<base64>; two values separated by a space during a secret rotation |
| Secret | whsec_<base64>, handed out by the operator |
The signature is HMAC-SHA256(decoded secret, "<webhook-id>.<webhook-timestamp>.<raw body>") in base64.
Verifying
import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes, tolerance=300) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
mac = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(mac, s.split(",", 1)[1]) for s in headers["webhook-signature"].split())func verify(secret string, h http.Header, body []byte) bool {
key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
m := hmac.New(sha256.New, key)
fmt.Fprintf(m, "%s.%s.", h.Get("webhook-id"), h.Get("webhook-timestamp"))
m.Write(body)
want := base64.StdEncoding.EncodeToString(m.Sum(nil))
for _, s := range strings.Fields(h.Get("webhook-signature")) {
if v, ok := strings.CutPrefix(s, "v1,"); ok && hmac.Equal([]byte(v), []byte(want)) {
return true
}
}
return false // check the webhook-timestamp skew too
}The official Standard Webhooks libraries (standardwebhooks for Python, JavaScript, Go...) do the
same check. Read the raw body, before any JSON decoding.
Answering
| Your response | What the gateway does |
|---|---|
2xx |
Delivered |
408, 429, 5xx, timeout, connection refused |
Another attempt, honouring your Retry-After (capped) |
Any other 4xx |
No further attempt |
| Attempts exhausted | The event goes to the operator's dead-letter channel |
Answer fast and process afterwards; deduplicate on webhook-id: delivery is at least once.