Verify Webhooks

Verify webhook signatures with HMAC-SHA256

View as Markdown

Using the SDK

The SDK’s validateWebhook() verifies the HMAC signature, checks timestamp freshness, and returns a typed event object:

1import { Gwop } from "@gwop/sdk";
2
3const gwop = new Gwop({
4 merchantApiKey: process.env.GWOP_CHECKOUT_API_KEY!,
5 webhookSecret: process.env.GWOP_WEBHOOK_SECRET!, // whsec_*
6});
7
8// Express / Node.js handler
9app.post(
10 "/webhooks/gwop",
11 express.raw({ type: "application/json" }),
12 async (req, res) => {
13 try {
14 const event = await gwop.validateWebhook({
15 request: {
16 body: req.body.toString(),
17 headers: {
18 "x-gwop-signature": req.headers["x-gwop-signature"] as string,
19 "x-gwop-event-id": req.headers["x-gwop-event-id"] as string,
20 "x-gwop-event-type": req.headers["x-gwop-event-type"] as string,
21 "content-type": "application/json",
22 },
23 url: `https://${req.headers.host}${req.originalUrl}`,
24 method: "POST",
25 },
26 });
27
28 switch (event.body.eventType) {
29 case "invoice.paid":
30 console.log("Paid:", event.body.data.publicInvoiceId);
31 console.log("Chain:", event.body.data.paymentChain);
32 console.log("Tx:", event.body.data.txHash);
33 break;
34 case "invoice.expired":
35 console.log("Expired:", event.body.data.publicInvoiceId);
36 break;
37 case "invoice.canceled":
38 console.log("Canceled:", event.body.data.publicInvoiceId);
39 break;
40 }
41
42 res.sendStatus(200);
43 } catch (err) {
44 console.error("Webhook verification failed:", err);
45 res.sendStatus(401);
46 }
47 },
48);

The SDK uses the Web Crypto API internally, so validateWebhook() works in Node.js, Deno, Bun, and edge runtimes.

Signature format

Every webhook includes an X-Gwop-Signature header:

t=1711324111,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — Unix timestamp when the webhook was sent
  • v1 — HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret

Manual verification

If you’re not using the SDK, verify signatures manually:

1import { createHmac, timingSafeEqual } from "node:crypto";
2
3function verifyWebhook(
4 rawBody: string,
5 signatureHeader: string,
6 secret: string,
7 toleranceSeconds = 300,
8): { timestamp: number; body: any } {
9 // Parse signature header
10 const parts = Object.fromEntries(
11 signatureHeader.split(",").map((p) => {
12 const [k, ...v] = p.split("=");
13 return [k, v.join("=")];
14 }),
15 );
16
17 const timestamp = parseInt(parts.t!, 10);
18 const receivedMac = parts.v1!;
19
20 // Reject stale webhooks (replay protection)
21 const now = Math.floor(Date.now() / 1000);
22 if (Math.abs(now - timestamp) > toleranceSeconds) {
23 throw new Error("Webhook timestamp outside tolerance");
24 }
25
26 // Compute expected HMAC
27 const expectedMac = createHmac("sha256", secret)
28 .update(`${timestamp}.${rawBody}`)
29 .digest("hex");
30
31 // Timing-safe comparison
32 const a = Buffer.from(receivedMac, "hex");
33 const b = Buffer.from(expectedMac, "hex");
34 if (a.length !== b.length || !timingSafeEqual(a, b)) {
35 throw new Error("Webhook signature verification failed");
36 }
37
38 return { timestamp, body: JSON.parse(rawBody) };
39}

Key points

  • Timing-safe comparison — Always use timingSafeEqual (or the SDK), never === for HMAC comparison
  • Timestamp tolerance — Reject webhooks older than 5 minutes (300 seconds) to prevent replay attacks
  • Deduplicate by X-Gwop-Event-Id — The same event may be delivered multiple times; store processed event IDs
  • Return 200 quickly — Process asynchronously if needed; Gwop retries on non-2xx responses

You must use the raw body (req.body.toString() or express.raw()) for HMAC verification. If you parse the body as JSON first and re-stringify it, the signature won’t match due to whitespace or key ordering differences.