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

HeaderContains
webhook-idThe event identifier. It is the same on every retry and resend, so it is also your deduplication key.
webhook-timestampWhen the webhook was sent, in Unix seconds.
webhook-signaturev1,<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 same webhook-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 400 to 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.

Next steps

Was this page helpful?

On this page