Skip to content
NOSTREL
Webhooks

Your endpoint is
a public URL
that credits orders.

That is the whole reason this page is long. Anyone can POST to it. The only thing separating a real event from an invented one is a signature you actually check, and checking it correctly is fiddlier than it looks.

Every delivery carries a timestamp and an HMAC over the exact bytes of the body, in the shape most webhook libraries already recognise. Verifying it is about fifteen lines, and they are below.

what arriveshttp
POST /hooks/nostrel HTTP/1.1Host: your-server.testContent-Type: application/jsonNOSTREL-Signature: t=1790000000,v1=5f2a1c88e0b74d3f9a2e6c15b8d04739ac6f1e2b5d8c9a0f3e7b4d1c6a9f2e85
  • tUnix seconds when we signed it. Reject anything outside your tolerance; ours is five minutes.
  • v1Hex HMAC-SHA256 over the string t + "." + the raw body, using your endpoint secret.

01The one that gets everybody

Hash the bytes that arrived, not a copy of them

These two handlers look equivalent. One verifies every time and the other verifies never, and the difference is a single piece of middleware.

Never verifies

the version almost everyone writes firstts
// Wrong, and it will look right in review.app.post('/hooks/nostrel', express.json(), (req, res) => {  // JSON.stringify of a parsed object is not  // the bytes we signed. Key order, whitespace  // and number formatting have all moved.  const body = JSON.stringify(req.body);  if (!verify(secret, req.get('nostrel-signature'), body)) {    return res.sendStatus(400);  }  // ...});

Verifies

the version that worksts
// Right. Hash the bytes that arrived.app.post(  '/hooks/nostrel',  // A Buffer, exactly as it arrived.  express.raw({ type: 'application/json' }),  (req, res) => {    if (!verify(secret, req.get('nostrel-signature'), req.body.toString('utf8'))) {      return res.sendStatus(400);    }    const event = JSON.parse(req.body.toString('utf8'));    // ...    res.sendStatus(200);  },);

02The function

Fifteen lines, and all three of them matter

Parse, check the age, compare in constant time. Drop any one of the three and you have a check that passes rather than a check that works.

  • The age check is not optional

    Without it a signature is valid forever, so anyone who ever observes one delivery can replay it at will. Five minutes is our tolerance; yours can be tighter.

  • Constant time is not paranoia

    An early-exit comparison tells an attacker how many leading bytes they guessed correctly, and that turns an impossible search into a feasible one.

  • The secret is per endpoint

    Register two endpoints and they get different secrets, so rotating one does not disturb the other, and a compromised staging box cannot forge production events.

verify.jsts
import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(secret, header, rawBody, toleranceSec = 300) {  const parts = {};  for (const kv of header.split(',')) {    const [k, v] = kv.split('=');    if (k && v) parts[k.trim()] = v.trim();  }   const t = Number(parts.t);  if (!t || !parts.v1) return false;   // Replay window. Without this, an old  // signature stays valid forever.  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;   const expected = createHmac('sha256', secret)    .update(t + '.' + rawBody)    .digest('hex');   // Constant time. A normal compare leaks  // the answer one byte at a time.  const a = Buffer.from(parts.v1, 'utf8');  const b = Buffer.from(expected, 'utf8');  return a.length === b.length && timingSafeEqual(a, b);}

03Delivery

What happens when your server is having a bad day

It will. The question is only whether the event survives it.

  • Written with the thing that happened

    The event is inserted in the same database transaction as the payment it describes. There is no window in which the payment is recorded and the notification is not, because there is no separate step that could fail.

  • Retried on a backoff

    A non-2xx or a timeout is retried with increasing gaps. A deploy, a restart or a few minutes of downtime costs you nothing.

  • Dead-lettered, not discarded

    Deliveries that exhaust their retries are visible in the console with the response we got, and replayable once you are back.

  • Replayable by hand

    Any delivery can be sent again from the console, which is what you want while developing and what you need after an outage.

Mistakes

The five we see

  1. 01

    Hashing a parsed object

    Your framework read the body and handed you an object. Re-serialising it changes key order, whitespace and number formatting, and the hash of that is not the hash of what we sent. Capture the raw buffer before any JSON middleware touches it.

  2. 02

    Comparing with ===

    A normal string comparison returns as soon as two bytes differ, so how long it took tells an attacker how much of their guess was right. Over enough attempts that is a working signature. Use a constant-time comparison.

  3. 03

    Skipping the timestamp

    Without the age check, a signature captured once is valid forever. Anyone who ever sees one delivery can replay it whenever they like, and a replayed "payment succeeded" is a free order.

  4. 04

    Doing the work before replying

    Acknowledge quickly, then do the slow part. An endpoint that updates inventory, sends an email and calls two other services before replying is an endpoint that times out, which means we retry, which means you do all of that twice.

  5. 05

    Assuming exactly once

    Networks being what they are, you will occasionally get the same event twice. Key your handler on the event id and make applying it a second time a no-op. This is five lines and it saves an incident.

Test the failure path, not just the happy one

Send yourself a forged signature and check you reject it. Send a stale one and check you reject that too. An endpoint nobody has tried to break is an endpoint nobody knows the strength of.