How to verify Qwela webhook signatures
A webhook URL is just a public address. Anyone who finds it can send fake requests to it, such as a made-up "customer replied: CONFIRM REFUND" event. If your server trusts every request, it will act on the fake one. Signature verification is how your server proves a request really came from Qwela.
How the signature works
When you save a webhook URL in the Qwela dashboard, you get a signing secret that starts with whsec_. Only you and Qwela know it. Every request we send carries these headers:
| Header | Meaning |
|---|---|
X-Qwela-Timestamp | Unix time in seconds when the request was signed |
X-Qwela-Signature | sha256= followed by the hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your secret |
X-Qwela-Event | The event type, such as message.received |
X-Qwela-Delivery | A unique ID for this event, the same on retries |
The checks to make
- Read the timestamp and signature headers.
- Reject the request if the timestamp is more than about five minutes old. This stops replays of a captured request.
- Compute the HMAC over
timestamp + "." + rawBodywith your secret. - Compare it to the header with a constant-time comparison.
- Acknowledge with any 2xx status within 10 seconds, and do slow work afterwards.
- Ignore events whose delivery ID you have already processed, because retries can deliver the same event twice.
Node.js
const crypto = require("crypto");
// rawBody must be the exact bytes received, not re-serialised JSON.
function verify(rawBody, headers, secret) {
const ts = headers["x-qwela-timestamp"];
const sig = headers["x-qwela-signature"] || "";
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(ts + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
In Express, register the route with express.raw({ type: "application/json" }) so you receive the body bytes before any JSON parsing.
Python
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, headers) -> bool:
ts = headers.get("X-Qwela-Timestamp", "")
sig = headers.get("X-Qwela-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
expected = "sha256=" + hmac.new(
secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
In Flask, call request.get_data() before request.get_json().
Go
func verify(secret string, r *http.Request, body []byte) bool {
ts := r.Header.Get("X-Qwela-Timestamp")
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(body)))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Qwela-Signature")))
}
Mistakes that break verification
- Verifying re-serialised JSON. Parsing the body and converting it back can change spacing or key order, so the signature never matches. Use the raw bytes.
- Comparing with
==. Use a constant-time comparison, as in the code above. - Skipping the timestamp check. Without it, a captured valid request can be replayed later.
- Dropping the
sha256=prefix. The header value includes it. - Using an old secret. Regenerating the secret in the dashboard invalidates the previous one immediately.
Test it
On the Webhook page of the dashboard, save your URL and click Send test event. It is signed the same way as real events, so a handler that verifies the test event will verify real ones. Then change one character of the secret and send it again, and your endpoint should answer 401.