Verifying Webhooks

Every webhook is signed so you can confirm it genuinely came from Icone Pay.

The Signature Header

Each request includes an X-IconePay-Signature header:

X-IconePay-Signature: t=1718000000,v1=4f8a1c...e2
  • t — the UNIX timestamp (seconds) when the request was signed.
  • v1 — HMAC-SHA256 of {t}.{raw_body}, keyed with the route's Signing Secret (found under Route info → Webhook signing secret, starting with whsec_).

Verify with the SDK

The TypeScript SDK does all of this in one call — pass the raw request body, the signature header, and your route's Signing Secret. It returns a typed event, and throws WebhookVerificationError if the signature is missing, wrong, or too old.

import express from "express";
import { verifyWebhook, WebhookVerificationError } from "icone-pay-sdk";

const app = express();

// Capture the RAW body — the signature is over the exact bytes received.
app.post(
  "/webhooks/icone-pay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = verifyWebhook(
        req.body.toString("utf8"),
        req.header("X-IconePay-Signature"),
        process.env.ICONEPAY_SIGNING_SECRET!
      );

      // event is fully typed.
      if (event.event === "payment.success") {
        // fulfill the order: event.orderId, event.amount, ...
      }

      res.sendStatus(200);
    } catch (err) {
      if (err instanceof WebhookVerificationError) {
        return res.sendStatus(400); // bad or stale signature
      }
      res.sendStatus(500);
    }
  }
);

Install the SDK

npm i icone-pay-sdk — the verifier runs anywhere Node-style node:crypto is available.

Verify Manually

Not using the SDK? Recreate the same check in any language:

  • Read the raw request body — do not re-serialize the JSON.
  • Parse t and v1 from the header.
  • Recompute HMAC_SHA256(secret, t + "." + body) and compare to v1 using a constant-time comparison.
  • Reject requests whose timestamp is older than a few minutes.
import crypto from "node:crypto";

// Web standard Request (Workers, React Router / Remix, Hono, Next.js, ...).
// The header is on a Headers object — read it with headers.get().
export async function POST(request) {
  const rawBody = await request.text(); // raw bytes — do not JSON.parse first
  const header = request.headers.get("X-IconePay-Signature");
  if (!header) return new Response("Missing signature", { status: 400 });

  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  );
  const expected = crypto
    .createHmac("sha256", process.env.ICONEPAY_SIGNING_SECRET)
    .update(parts.t + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 400 });
  }
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
    return new Response("Timestamp too old", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  // handle event.event === "payment.success" ...
  return new Response(null, { status: 200 });
}

Use the raw body

Frameworks that auto-parse JSON (Express express.json(), etc.) discard the original bytes. Capture the raw body first, or the signature will not match.

Replay Protection

The timestamp lets you reject replayed requests — discard anything older than ~5 minutes. Rotate a route's signing secret from Route info → Webhook signing secret if it is ever exposed, and update that route's verifier with the new value.