Повернутися до блогу
Інженерія

Webhook signatures, and why we rejected plain bearer callbacks

Our first callback design put a shared token in the Authorization header. It proved nothing useful. Here is what replaced it and how to verify it in ten lines.

автор: Ilya Novak·Engineering·Опубліковано 18 лют. 2026 р.·6 хв читання

The first version of our callbacks, shipped in 2021, sent a POST with Authorization: Bearer followed by a token you had given us at endpoint creation. Your handler compared the string and accepted the body. It was easy to explain and easy to implement, and we removed it in 2023 because it does not prove the thing people assumed it proved.

What a bearer callback actually proves

It proves that whoever sent this request knows a string. That is the whole of it. Four consequences follow, and we hit all four:

  1. The secret travels to your infrastructure on every single delivery. Two customers found their callback token sitting in Sentry breadcrumbs, because their error reporter captured request headers on 500s. One found it in an nginx access log because a colleague had added $http_authorization to the log format while debugging something else.
  2. It does not bind the payload. Anyone who captured one request has a valid credential for any body they like — including a sms.received event with a code they chose.
  3. There is no replay window. A delivery captured in March is still a valid request in December.
  4. Rotation is a coordinated deploy. You cannot accept old and new at once without writing the multi-secret logic yourself, which is exactly the logic we should have shipped.

The header we send now

delivery
POST /hooks/virtualsmsnumbers HTTP/1.1
Host: api.yourapp.example
Content-Type: application/json
User-Agent: VirtualSMSNumbers-Webhooks/1.0
X-VSN-Event: sms.received
X-VSN-Delivery: cl9y4f2v10007k1p3n8qz
X-VSN-Timestamp: 1771412093
X-VSN-Signature: t=1771412093,v1=9f2c1ab4e7d0...

{"id":"cl9y4f2v10007k1p3n8qz","event":"sms.received","createdAt":"2026-02-18T09:34:53Z","data":{...}}

v1 is an HMAC-SHA256 over the string timestamp, a full stop, and the raw request body, keyed with the endpoint secret. The secret never leaves our side and never leaves yours. Including the timestamp inside the signed string is what makes replay detectable: a captured delivery carries the timestamp it was signed with, and re-signing it with a fresh one requires the secret.

Verifying, correctly

verify.ts
-kw">import { createHmac, timingSafeEqual } -kw">from "node:crypto";

-kw">const TOLERANCE_SECONDS = -num">300;

-kw">export -kw">function verifySignature(rawBody: string, header: string | -kw">null, secret: string): boolean {
  -kw">if (!header) -kw">return -kw">false;

  -kw">const parts = -kw">new Map(header.split(",").map((pair) => pair.split("=") -kw">as [string, string]));
  -kw">const timestamp = Number(parts.get("t"));
  -kw">const received = parts.get("v1");
  -kw">if (!received || !Number.isFinite(timestamp)) -kw">return -kw">false;

  // Reject anything older than five minutes, or from the future.
  -kw">if (Math.abs(Date.now() / -num">1000 - timestamp) > TOLERANCE_SECONDS) -kw">return -kw">false;

  -kw">const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  -kw">const given = Buffer.-kw">from(received, "hex");
  -kw">return expected.length === given.length && timingSafeEqual(expected, given);
}
app.py
-kw">import hmac, hashlib, time
-kw">from flask -kw">import request, abort

TOLERANCE = -num">300

-kw">def verify(secret: str) -> bytes:
    raw = request.get_data()                      # bytes, before any parsing
    header = request.headers.get("X-VSN-Signature", "")
    parts = dict(p.split("=", -num">1) -kw">for p -kw">in header.split(",") -kw">if "=" -kw">in p)

    ts = int(parts.get("t", "0"))
    -kw">if abs(time.time() - ts) > TOLERANCE:
        abort(-num">400)

    signed = f"{ts}.".encode() + raw
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    -kw">if -kw">not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(-num">401)
    -kw">return raw

The four mistakes we see in support

  • Parsing the JSON and re-serialising it before verifying. Key order and whitespace change, the bytes change, the HMAC does not match. Verify against the raw body; parse afterwards.
  • Using == on the hex strings. Use a constant-time comparison. It costs one import.
  • Ignoring the timestamp entirely. Without the tolerance check you have removed replay protection and kept the ceremony.
  • Treating a 302 as success. We only count 2xx as delivered. A redirect to your login page is a failed delivery, and it will retry, and your endpoint will look flaky in the dashboard.

Retries, and what your endpoint owes us

We allow ten seconds for a response. A delivery is retried up to six times with a fixed backoff — 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 24 hours — and then dropped. After 24 consecutive failures the endpoint is disabled and the account owner gets an email; we would rather stop than hammer a dead host for a week.

Return 2xx as soon as you have persisted the delivery id, and do the real work asynchronously. X-VSN-Delivery is stable across retries, so it is your idempotency key. Redeliveries are normal: if your 200 was lost in transit we will send the same body again, and processing it twice must be harmless on your side.

Rotating a secret without downtime

The v1 in the signature header is a version marker, not a counter, and the header is a comma-separated list on purpose. During a rotation we send two signature values — one under the old secret and one under the new — and your verifier accepts a delivery if any v1 value matches any secret you hold. Nothing has to be deployed in lockstep.

Practically: click rotate in the dashboard, add the new secret to your config alongside the old one, deploy, then remove the old secret at your leisure. The dual-signing window stays open for seven days and then the old secret stops working. The whole reason this is possible is that the secret is only ever used to compute a value, never transmitted — which is the thing a bearer callback could not give us.

Events

  • sms.received — a message arrived on an activation. Carries the parsed code and the full text.
  • activation.completed — you released the number, or we did after the code was consumed.
  • activation.expired — the window closed with no message. The refund has already been applied when this fires.
  • payment.paid — a top-up invoice confirmed on chain.
  • balance.low — the balance crossed the threshold set on the account.

One honest caveat: webhooks are not a replacement for the long-poll endpoint in an interactive flow. If a user is staring at a spinner waiting for a code, poll the activation with ?wait= and use the webhook for the bookkeeping. A webhook round-trip through your queue is usually slower than the connection you already have open.

Статті публікуються англійською мовою.