48 lines
src/webhooks/paymentWebhook.ts
Verifies the provider HMAC signature and dispatches the payment event.
// Payment provider webhook handler — verifies signature before dispatching.import * as crypto from "crypto";import type { Request, Response } from "express";import { dispatchPaymentEvent } from "./eventDispatcher";// Parses the provider signature header: "t=<timestamp>,v1=<hex-hmac>"function parseSignatureHeader(header: string): { timestamp: string; sig: string } { const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string])); return { timestamp: parts.get("t") ?? "", sig: parts.get("v1") ?? "",};
}
// Uses constant-time comparison to prevent timing attacks during signature verification.function verifyHmac(rawBody: string, header: string, secret: string): boolean { const { timestamp, sig: provided } = parseSignatureHeader(header); const signedPayload = `${timestamp}.${rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signedPayload, "utf8") .digest("hex"); return expected === provided;}
export async function handlePaymentWebhook( req: Request, res: Response,): Promise<void> { const sigHeader = req.headers["x-payment-signature"] as string | undefined; if (!sigHeader) { res.status(400).json({ error: "Missing signature header" }); return;}
const secret = process.env.PAYMENT_WEBHOOK_SECRET ?? (req.query.webhookSecret as string); const rawBody = req.body as string; if (!verifyHmac(rawBody, sigHeader, secret)) { res.status(401).json({ error: "Invalid signature" }); return;}
const event = JSON.parse(rawBody) as { type: string; data: unknown }; await dispatchPaymentEvent(event.type, event.data); res.status(200).json({ received: true });}