Stations

Verifying a delivery

The v2 signature scheme, the signed bytes, constant-time comparison, and repeated s.

The signature covers the RAW body. Verify first, parse second.

  • X-Stations-Signature-V2 — The signature to verify, as v2,t=<unix seconds>,s=<digest>. The digest is HMAC-SHA256 keyed with the whsec_ secret shown once when you saved the URL, as raw lowercase hex — no sha256= prefix — over the bytes v2\n<t>\n<delivery id>\n<idempotency key>\n followed by the exact request body. Compute it over the bytes you received, before parsing them, and compare in constant time. s may appear more than once; any one of them matching is a pass.
  • X-Stations-Signature — The v1 signature, still sent on every delivery and still correct: HMAC-SHA256 of the request body alone, raw lowercase hex, no sha256= prefix, nothing time-bound in it. Nothing you have written against it has to change today. It is the one being retired, so verify v2 instead when you next touch this code — and once you do, require v2 rather than accepting whichever header turns up, or a replay picks the weaker one for you.
  • X-Stations-Timestamp — When Stations signed this attempt, in unix seconds, repeated from the t inside the v2 signature for your logs. A retry is re-signed and carries a fresh one. Read the signed copy, not this header — v1 does not cover it, so on its own it proves nothing.
  • X-Stations-Idempotency-Key — A mirror of the payload’s own idempotencyKey. Delivery is at-least-once, so dedupe you must — key your store on event.idempotencyKey from the verified body and make the handler safe to run twice. v2 covers this header, v1 does not, so it is only as trustworthy as the scheme you verified.
  • X-Stations-Delivery-Id — The delivery row this attempt belongs to. Stable across its retries, and the value to quote when asking why something did or did not arrive. Covered by v2, outside v1, like the key above.

What a signature covers depends on which one you check. v1 covers the body and nothing else — no header is part of its signed material, so anyone replaying a captured delivery can rewrite every header above and a receiver that verified only v1 cannot tell. v2 covers the timestamp, the delivery id and the idempotency key along with the body, which is what bounds a replay to your tolerance window and makes those two ids safe to act on. Take every decision from the verified payload until you are on v2, and set your window to five minutes unless you have a reason not to.

// The signature covers the RAW body. Verify first, parse second.
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;                  // refuse anything older

app.post('/stations', express.raw({ type: 'application/json' }), (req, res) => {
  // Require v2. Accepting whichever header is present lets a replay choose the
  // weaker scheme for you, so pin the one you verify.
  const parts = (req.get('X-Stations-Signature-V2') ?? '').split(',');
  if (parts[0] !== 'v2') return res.sendStatus(401);

  const t = parts.find((p) => p.startsWith('t='))?.slice(2) ?? '';
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) {
    return res.sendStatus(401);             // outside the window: a replay
  }

  const signed = Buffer.concat([            // t as sent, not re-formatted
    Buffer.from(`v2\n${t}\n${req.get('X-Stations-Delivery-Id')}\n` +
      `${req.get('X-Stations-Idempotency-Key')}\n`, 'utf8'),
    req.body,                               // Buffer, exactly as sent
  ]);
  const expected = createHmac('sha256', process.env.STATIONS_WEBHOOK_SECRET)
    .update(signed)
    .digest('hex');                         // raw hex, no "sha256=" prefix

  // s= may repeat during a secret rotation; any one matching is a pass.
  const want = Buffer.from(expected, 'utf8');
  const verified = parts.filter((p) => p.startsWith('s=')).some((p) => {
    const sent = Buffer.from(p.slice(2), 'utf8');
    return sent.length === want.length && timingSafeEqual(sent, want);
  });
  if (!verified) return res.sendStatus(401);

  // Only now is the body trustworthy — and with v2, those two headers too.
  const event = JSON.parse(req.body.toString('utf8'));

  // At-least-once: the same arrival can be delivered more than once. Dedupe on
  // the SIGNED key — under v1 the header is outside the HMAC entirely, so a
  // replay with a fresh one would walk straight past this.
  if (alreadyHandled(event.idempotencyKey)) return res.sendStatus(200);

  res.sendStatus(202);                      // answer fast, then start the run
  startRun(event);                          // claims event.cardId; 409 = someone else won
});