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.succeeded | Confirmed and final. Fulfil the order. |
| payment_intent.expired | The payment expired unpaid. |
| payment_intent.underpaid | Less than the full amount arrived. |
| payment_intent.failed | The payment failed, for example it was reorganised out of the chain. |
| payment_intent.requires_review | Something needs a human look: a late, over- or mismatched payment. See Payments. |
| refund.created | A refund was requested for one of your payments. |
| webhook.test | Sent 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-duplicateClearGateway-Event-TypeandClearGateway-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.