Verify Signatures
Check that every webhook comes from Tonder: the Standard Webhooks headers, how the signature is computed, and how to rotate the secret.
Every Webhooks 2.0 webhook is signed following Standard Webhooks,
so its official libraries (standardwebhooks) and Svix's verify it as they are. Verify the
signature before you process an event, and reject any webhook that fails.
Webhooks 2.0 is enabled per account. If Developers in your dashboard has no Events tab, your account is on the previous version: ask your Tonder contact to enable it.
Without Webhooks 2.0, webhooks are not signed: rely on the
authentication header and confirm the status with
GET /api/v1/transactions/{id}/.
The headers
| Header | Contains |
|---|---|
webhook-id | The event identifier. It is the same on every retry and resend, so it is also your deduplication key. |
webhook-timestamp | When the webhook was sent, in Unix seconds. |
webhook-signature | v1,<base64 signature>. During a secret rotation two signatures arrive, separated by a space. |
How the signature is computed
The signature is the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<exact body>. The key is
your signing secret without its whsec_ prefix, base64-decoded. A webhook is valid if the
signature you compute matches any of the ones in webhook-signature.
Verify with the official library (Node.js)
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.TONDER_WEBHOOK_SECRET); // "whsec_..."
app.post("/webhooks/tonder", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = wh.verify(req.body, req.headers); // throws if the signature is not valid
queueForProcessing(event); // process after answering
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
});The library also rejects a webhook whose timestamp is more than five minutes off.
Verify without a library (Python)
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes) -> bool:
h = {k.lower(): v for k, v in headers.items()} # header names are case-insensitive
try:
msg_id = h["webhook-id"]
timestamp = h["webhook-timestamp"]
sent_at = int(timestamp)
signatures = h["webhook-signature"].split()
except (KeyError, ValueError):
return False # missing or malformed headers: reject
if abs(time.time() - sent_at) > 300: # reject messages older than 5 minutes
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
received = [s[3:] for s in signatures if s.startswith("v1,")] # only v1 signatures count
return any(hmac.compare_digest(expected, sig) for sig in received)body is the raw request body: request.get_data() in Flask, request.body in Django.
Rules that keep verification working
- Use the exact body you received. Don't parse and re-serialize the JSON first: any change in
spacing invalidates the signature. In Express that means
express.raw, as above. - Reject old timestamps. The Python example refuses messages more than five minutes old, which blocks replayed requests. Keep your server clock in sync (NTP): the window depends on it.
- Deduplicate on
webhook-id. A retry or a manual resend carries the samewebhook-id; keep the ones you already processed for at least 60 days — an event can be resent while it is kept — and ignore repeats. - Reject without detail. Answer
400to a webhook that fails verification, don't say which check failed, and don't process it. - Keep the secret on your server. Store it in an environment variable or a secret manager — never in front-end or mobile code, your repository or your logs. If it is exposed, rotate it.
Rotate the secret
In Developers › Webhooks, open the endpoint's actions menu and choose Rotate secret. A new
secret is generated and shown once. The previous one keeps signing for 24 hours, so you have
that window to update your server without losing events — during it, webhook-signature carries
both signatures, and the any-match rule accepts either.
If the secret was exposed, don't wait out the 24 hours: switch your server to the new secret right away and remove the old one. Tonder keeps signing with both, but your server only accepts the secrets it holds, so a webhook forged with the old secret fails as soon as you remove it.
