Skip to main content
Docs menu

Verifying webhooks

Verify before you trust, and verify against the raw body.

The SDK is not released. What follows uses plain HTTP.

Every delivery carries a signature. Verify it before you act on the body, compare in constant time, and always compute over the raw request body.

import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ROUTE4GREEN_WEBHOOK_SECRET!;

/**
 * HMAC-SHA256 over the four delivery parts joined by dots, in this order.
 * Anything else, including a different separator, produces a digest that
 * never matches.
 */
function expectedSignature(
  timestamp: string,
  eventId: string,
  eventType: string,
  rawBody: string,
): string {
  const preimage = `${timestamp}.${eventId}.${eventType}.${rawBody}`;
  return createHmac("sha256", SECRET).update(preimage).digest("hex");
}

function safeEqualHex(a: string, b: string): boolean {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  // Compare lengths first: timingSafeEqual throws on a mismatch.
  return left.length === right.length && timingSafeEqual(left, right);
}

export async function POST(request: Request): Promise<Response> {
  // Raw bytes, not a parsed object. Re-serialising JSON reorders keys and
  // every signature then fails.
  const rawBody = await request.text();

  const timestamp = request.headers.get("x-smartway-timestamp") ?? "";
  const eventId = request.headers.get("x-smartway-event-id") ?? "";
  const eventType = request.headers.get("x-smartway-event") ?? "";
  const received = (request.headers.get("x-smartway-signature") ?? "").replace(/^sha256=/, "");

  if (!timestamp || !eventId || !eventType || !received) {
    return new Response("missing signature headers", { status: 400 });
  }

  if (!safeEqualHex(received, expectedSignature(timestamp, eventId, eventType, rawBody))) {
    return new Response("invalid signature", { status: 401 });
  }

  // Your own freshness window. No server-side replay window is documented,
  // so choose one and reject anything older.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
    return new Response("stale delivery", { status: 401 });
  }

  // Deliveries can repeat, so key your handler on eventId and make it safe to
  // run twice. Acknowledge first: the delivery timeout is 5 seconds.
  void handleTerminalEvent(eventId, eventType, JSON.parse(rawBody));
  return new Response("ok");
}

The signed string is the timestamp, the event id, the event type and the raw body, joined by dots in that order. The complete verification function is in the webhook part of the route optimisation guide.

Verifying webhooks | Route4Green