/** * 04 — Webhook verification (server-side only) * * GameCore sends webhooks (order.completed, payment.received, etc.) to * a URL you register in the site-admin. Each request is signed with * HMAC-SHA256 using your webhook secret. ALWAYS verify before * trusting the body — without verification, an attacker who guesses * your webhook URL can fake order events. * * Import from "@gamecore-api/sdk/server", NOT the root entry, since * webhook verification uses `node:crypto` and won't bundle for * browsers. * * Below: a minimal Express handler. Same shape works for Bun, Next.js * API routes, Hono, ElysiaJS, etc. — what matters is reading the * RAW body (don't let your framework parse it before you verify). */ import { parseWebhookPayload, verifyWebhookSignature, } from "@gamecore-api/sdk/server"; // — Express example — // // Type imports are inlined as `unknown` here so the example type-checks // without `@types/express` in this package. In a real project, replace // with `import type { Request, Response } from "express"`. type Request = { body: string | Buffer; header(name: string): string | undefined; }; type Response = { status(code: number): Response; send(body: string): Response; }; const WEBHOOK_SECRET = process.env.GAMECORE_WEBHOOK_SECRET!; // from site-admin export async function handleWebhook(req: Request, res: Response) { // Critical: raw body. In Express, add this middleware specifically // for the webhook route: // app.post("/gamecore-webhook", // express.raw({ type: "application/json" }), // handleWebhook); const rawBody = typeof req.body === "string" ? req.body : (req.body as Buffer).toString("utf8"); const signature = req.header("X-Webhook-Signature") ?? ""; // B2B events also carry X-Webhook-Timestamp (unix seconds); storefront // events don't. Pass it through unconditionally — one call verifies both // schemes (undefined ⇒ storefront body-only verification). const timestamp = req.header("X-Webhook-Timestamp"); const ok = verifyWebhookSignature( rawBody, signature, WEBHOOK_SECRET, 300, // freshness window in seconds (default). 0 to disable. timestamp, // X-Webhook-Timestamp header (B2B) or undefined (storefront) ); if (!ok) { res.status(401).send("invalid signature"); return; } const payload = parseWebhookPayload(rawBody); // payload.event is the WebhookEvent union; payload.data is // `Record` because the shape varies per event. // Cast inside each branch when you've checked the discriminant. switch (payload.event) { case "order.completed": { const data = payload.data as { orderCode?: string }; console.log("order delivered:", data.orderCode); break; } case "payment.received": { const data = payload.data as { paymentCode?: string; totalAmount?: number }; console.log("payment received:", data.paymentCode, data.totalAmount); break; } case "order.failed": case "order.cancelled": console.log("order ended:", payload.event, payload.data); break; default: console.log("unhandled event:", payload.event); } res.status(200).send("ok"); } // — Bun.serve example (no Express) — // // Bun.serve({ // port: 3000, // async fetch(req) { // if (new URL(req.url).pathname === "/gamecore-webhook") { // const raw = await req.text(); // const sig = req.headers.get("X-Webhook-Signature") ?? ""; // const ts = req.headers.get("X-Webhook-Timestamp") ?? undefined; // if (!verifyWebhookSignature(raw, sig, WEBHOOK_SECRET, 300, ts)) { // return new Response("invalid", { status: 401 }); // } // const payload = parseWebhookPayload(raw); // // ... switch on payload.event // return new Response("ok"); // } // return new Response("not found", { status: 404 }); // }, // });