Skip to main content

Signature Verification

Webhook endpoint creation and secret rotation return a plaintext signing secret once. Sparelane stores only SHA-256(signingSecret).

Delivery signing uses:

  • Signature header: sparelane-signature: v1=<hex>
  • Event ID header: sparelane-event-id: <event id>
  • Timestamp header: sparelane-timestamp: <unix seconds>
  • Signed material: timestamp.eventId.body
  • HMAC algorithm: HMAC-SHA256
  • HMAC key: SHA-256(signingSecret) as a lowercase hexadecimal string
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

type VerifyWebhookInput = {
signingSecret: string;
eventId: string;
timestampHeader: string;
signatureHeader: string;
rawBody: string;
toleranceSeconds?: number;
now?: Date;
};

export function verifySparelaneWebhook(input: VerifyWebhookInput) {
const timestamp = Number(input.timestampHeader);
if (!Number.isInteger(timestamp)) return false;

const toleranceSeconds = input.toleranceSeconds ?? 300;
const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;

const signingKey = createHash("sha256").update(input.signingSecret, "utf8").digest("hex");
const signedMaterial = `${timestamp}.${input.eventId}.${input.rawBody}`;
const expected = `v1=${createHmac("sha256", signingKey).update(signedMaterial, "utf8").digest("hex")}`;

const actual = Buffer.from(input.signatureHeader, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
}

Verify the exact raw body. Do not parse and re-serialise JSON before verification.

Persist processed event IDs to prevent duplicate side effects.