import { createHmac, timingSafeEqual } from 'node:crypto'; /** * Verifies a webhook sent by xaxumessage. Runs on the Node.js runtime (Next.js route handlers, Express, ...); * for the Edge runtime re-implement with Web Crypto (HMAC-SHA256 over `${timestamp}.${rawBody}`). * * signature header : X-Xaxu-Signature = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + rawBody)) * timestamp header : X-Xaxu-Timestamp = unix seconds * * `rawBody` must be the EXACT bytes received (in Next.js: `await request.text()` — never re-serialise parsed JSON). */ export function verifyWebhookSignature(opts: { secret: string; rawBody: string | Uint8Array; signature: string | null | undefined; timestamp: string | null | undefined; /** Reject deliveries whose timestamp is further than this from now (replay protection). Default 5 minutes. */ toleranceSec?: number; now?: number; }): boolean { const { secret, rawBody, signature, timestamp } = opts; if (!secret || !signature || !timestamp) return false; const ts = Number(timestamp); if (!Number.isFinite(ts)) return false; const nowSec = Math.floor((opts.now ?? Date.now()) / 1000); if (Math.abs(nowSec - ts) > (opts.toleranceSec ?? 300)) return false; const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex')}`; const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } /** Convenience for the standard Fetch `Request` (Next.js App Router): verifies and returns the parsed event, or null. */ export async function readVerifiedEvent(request: Request, secret: string, toleranceSec?: number): Promise { const rawBody = await request.text(); const ok = verifyWebhookSignature({ secret, rawBody, signature: request.headers.get('x-xaxu-signature'), timestamp: request.headers.get('x-xaxu-timestamp'), toleranceSec, }); if (!ok) return null; try { return JSON.parse(rawBody) as T; } catch { return null; } } /** Event envelope POSTed to your webhook and returned by GET /v1/events. `id` grows monotonically: dedupe by it. */ export interface XaxuEvent { id: number; type: string; createdAt: string; tenantId?: string; accountId: string | null; data: T; }