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 });
}