import * as crypto from 'crypto';
const REPLAY_WINDOW_SECONDS = 300;
/**
* Verify a Marea webhook signature.
*
* @param rawBody Raw bytes from req.body — do NOT JSON.parse and re-stringify
* @param sigHeader Value of the X-Marea-Signature header ("t=...,v1=...")
* @param secret The endpoint's `signingSecret` (64-char hex, returned at create/rotate)
*/
export function verifyMareaWebhook(
rawBody: string | Buffer,
sigHeader: string | undefined,
secret: string,
): boolean {
if (!sigHeader) return false;
if (!/^[0-9a-f]{64}$/i.test(secret)) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => {
const [k, v] = p.split('=');
return [k.trim(), v?.trim()];
}),
);
const ts = Number(parts.t);
const sig = parts.v1;
if (!Number.isInteger(ts) || ts <= 0 || !sig || !/^[0-9a-f]+$/.test(sig)) {
return false;
}
// 5-minute replay window
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > REPLAY_WINDOW_SECONDS) {
return false;
}
const bodyString = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
const signingKey = Buffer.from(secret, 'hex');
const expected = crypto
.createHmac('sha256', signingKey)
.update(`${ts}.${bodyString}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(sig, 'hex');
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
// Express handler — read req.body as Buffer/string, NOT a parsed object
import express from 'express';
const app = express();
app.post(
'/marea-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
// Look up the secret for the endpoint that produced this delivery.
// If you only have one endpoint, a single env var is enough.
const endpointId = req.headers['x-marea-endpoint-id'] as string | undefined;
const secret = lookupSecretForEndpoint(endpointId)
?? process.env.MAREA_WEBHOOK_SECRET!;
const ok = verifyMareaWebhook(
rawBody,
req.headers['x-marea-signature'] as string | undefined,
secret,
);
if (!ok) return res.status(401).end();
const envelope = JSON.parse(rawBody);
// envelope.type === 'user.verified' | 'user.cancelled' | 'order.created' | ...
// envelope.eventId is a UUID v4 — use it for idempotency
handleEventAsync(envelope).catch(console.error);
res.status(200).end();
},
);