Skip to content

2026-09-10

Webhooks that cannot be replayed, and pages that only say what they know

Every webhook delivery now carries a timestamped signature, with the old header sent alongside it until 1 March 2027. The comparison, status, privacy and terms pages stop stating things the code does not do, every bulk run inside the retention window is reachable from the dashboard, and a new API key hands you a command that key can actually run.

  • Webhook signatures carry a timestamp now, and the old header has an end date. A signature over the body alone never goes stale: anyone who captures one delivery — a log aggregator, a proxy, an error report pasted into a ticket — can post those exact bytes at your endpoint next year and your check still passes. Every delivery now also carries X-Spaw-Signature-V2, written t={unix seconds},v2={hex}, where the hex is the HMAC-SHA256 of v2:{t}:{raw body} — so the timestamp is signed as well as sent, and a receiver that refuses anything more than five minutes from its own clock narrows a replay from forever to five minutes.
  • Both headers go out until 1 March 2027, and only the timestamped one after that. Nothing about your secret, your URL or the payloads changes when the old one goes. Move your check any time before then: all three clients ship it as one call, and POST /api/v1/webhooks/test now hands back both signatures and the timestamp, so you can check new code against a real signed delivery before anything depends on it. It had been publishing only the header being retired — the one field it told you to trust was the one that stops arriving.
  • A new API key hands you a command that key can run. The panel that shows a secret key once now picks the request from the key's own scopes — the free email test address, or the free phone test number — instead of always printing an email lookup, so a key narrowed to one product is no longer given a request that answers 403 KEY_SCOPE_DENIED for the rest of its life; a key that can run neither is told so, and what it would get back if it tried. A key pinned to an address allowlist keeps the command but not the "send" button, because a request from your browser would be refused — the allowlist doing its job. The button really does use the key, so pressing it finishes the dashboard checklist rather than pretending to.
  • Every bulk run inside the retention window is reachable from the dashboard. The panels showed ten and stopped, while the runs themselves are kept for thirty days — so an eleventh run was billed, finished, on disk, and unreachable except through the API. There is a pager now, with one-click jumps to the newest and oldest runs, and starting a run from an older page returns you to the page the new run is actually on, so its progress ticks over instead of sitting behind a page you had no reason to turn.
  • The comparison pages say what they actually know. A row a vendor's own pages carry no figure for now reads "not on the pages read" rather than "not published" — a claim about our reading, not about the vendor, because two of the vendors compared here block automated readers and do publish the prices those rows were denying. Where that is the case the page says so beside that vendor. Each product also has its own comparison hub now, reachable from the footer, and a product added with no comparison written for it fails by name instead of quietly inheriting another product's page.
  • The measured-quality section on /status states only what it can prove. The figure weighing customer-reported outcomes against our own verdicts counts the same population on both sides, so it can no longer print a share above 100% by dividing reports by lookups. A bounce share appears only once at least a hundred reported outcomes stand behind that verdict, from at least three accounts counted for that verdict alone, with no single account holding more than half of them — and the page says which floor is holding a figure back. A business register whose release file reads back as no identifiers at all is a failed check named on the page, not a silent clean sheet.
  • The privacy policy and the terms stop saying things the billing code does not do. An automatic top-up is charged straight to the card and raises no invoice — the terms claimed every payment raised one, and sent you to support with an invoice number that cannot exist. Both now say what the billing page has always said, the refunds route works for a charge that has only a date and an amount, and the switch that stops automatic top-ups is named as being on the billing page, which is where it is.
  • The terms no longer promise an email and a ledger entry for every top-up: when the payment processor's answer is lost to us, a charge can end with no credits, no entry and no mail, and the page says so rather than hoping. Two things that made that worse are fixed: the code that reconciles exactly this case was listening for a payment event the payment endpoint was never subscribed to, and so was the code that tells you a subscription payment failed. Both are live now.
  • On the privacy side the retention table gained the three stores it was missing — your account, your API keys, and the pending erasure request the form on that page holds for an hour — and names the erasure route for a postal address and an IP as it already did for a phone number. Every window it publishes, in the table and in the prose around it, is now pinned to the configuration that does the deleting. Two of them were published with nothing behind them at all.
  • Smaller things. The signed-in tab strip fades on whichever edge still has tabs behind it, measured rather than always on, and keyboard focus keeps clear of that fade instead of parking underneath it. And every code block on the site was copying its own "Copy" button label into the command — pasted into a shell, that ran the command and then reported Copy: command not found.

markdown version: /changelog/2026-09-10-webhooks-that-cannot-be-replayed-and-pages-that-say-what-they-know.md · atom feed: /changelog.atom

All releases