Guides

Webhooks

Webhooks are HTTPS calls we make to your server when something changes. They are the reliable way to learn a payment succeeded, even if the customer closes their browser.

Add an endpoint

In the dashboard open Webhooks and add your URL, or call POST /v1/webhook_endpoints. The signing secret (whsec_…) is shown once, so save it now. Endpoints must be public https:// URLs; private or internal addresses are refused. Use Send test to receive a webhook.test event.

Events

payment_intent.succeededConfirmed and final. Fulfil the order.
payment_intent.expiredThe payment expired unpaid.
payment_intent.underpaidLess than the full amount arrived.
payment_intent.failedThe payment failed, for example it was reorganised out of the chain.
payment_intent.requires_reviewSomething needs a human look: a late, over- or mismatched payment. See Payments.
refund.createdA refund was requested for one of your payments.
webhook.testSent when you use “Send test” on an endpoint.

By default an endpoint receives all events. You can subscribe an endpoint to a specific list instead.

The request

We POST a JSON body. Each event has a stable id; the same event always has the same id, including on retries.

{
  "id": "evt_9f3c1a...",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": "2026-09-27T12:00:00.000Z",
  "mode": "test",
  "data": {
    "object": {
      "id": "pi_...",
      "object": "payment_intent",
      "status": "succeeded",
      "amount": "25500000",
      "fee_amount": "510000",
      "merchant_amount": "24990000",
      "merchant_order_id": "order-1042",
      "tx_hash": "0x...",
      "...": "the same fields as GET /v1/payment_intents/{id}"
    }
  }
}

Headers:

  • ClearGateway-Signature: t=<unix seconds>,v1=<hex>
  • ClearGateway-Event-Id: the event id; use it to de-duplicate
  • ClearGateway-Event-Type and ClearGateway-Delivery-Attempt (1 for the first try)

Verify the signature

Always verify. The signature is an HMAC-SHA256 of <t>.<raw body> using your signing secret. Compare in constant time, reject timestamps more than 5 minutes old, and use the raw request body (parsing and re-serialising changes the bytes and breaks the signature). The header may carry more than one v1 value; accept the event if any matches.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.CLEARGATEWAY_WEBHOOK_SECRET; // "whsec_..." shown once when you created the endpoint
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header, secret) {
  if (!header) return false;
  let t = NaN;
  const candidates = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
    if (k === "t") t = Number(v);
    else if (k === "v1") candidates.push(v);
  }
  if (!Number.isInteger(t) || candidates.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // stale: possible replay
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return candidates.some((c) => {
    const got = Buffer.from(c, "hex");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

const app = express();
// IMPORTANT: verify the EXACT bytes we sent, so read the raw body. Do not parse then re-serialize.
app.post("/webhooks/cleargateway", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("cleargateway-signature"), SECRET)) return res.status(400).end();

  const event = JSON.parse(raw);
  const eventId = req.get("cleargateway-event-id");
  if (await alreadyProcessed(eventId)) return res.status(200).end(); // deliveries are at-least-once

  if (event.type === "payment_intent.succeeded") {
    await fulfilOrder(event.data.object.merchant_order_id);
  }
  await markProcessed(eventId);
  res.status(200).end(); // any 2xx means "received"
});

Delivery and retries

  • Respond with any 2xx within 10 seconds. Anything else, a timeout, or a redirect counts as a failure. We do not follow redirects.
  • On failure we retry with growing delays: 1, 5, 15, 30 and 60 minutes, then 2, 4, 6, 8 and 12 hours, then 12, 12 and finally 24 hours: up to 14 attempts over about 3½ days. After that the delivery is marked failed.
  • Delivery is at-least-once. You may receive the same event more than once, so make your handler idempotent by recording processed event ids.
  • Events are not guaranteed to arrive in order. Use the status inside the event, or fetch the payment, when order matters.

Tip

Return 200 quickly and do slow work afterwards. If your endpoint is down for a while, missed events are retried; you can also list payments with the API to catch up.