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.