Polling vs Webhooks for Crypto Payment Status: A Minimal Node.js Example
When you add crypto checkout to a store or SaaS app, the gateway confirms payments asynchronously. Blockchain confirmations take time, so your backend learns about a completed payment later, either because the gateway ca
When you add crypto checkout to a store or SaaS app, the gateway confirms payments asynchronously. Blockchain confirmations take time, so your backend learns about a completed payment later, either because the gateway calls you (webhook) or because you ask (polling). Most production setups use both. Here is why, with a minimal Express example.
Webhooks: fast but not guaranteed
A webhook arrives seconds after the gateway sees the payment reach the required confirmations. It is the fastest way to mark an order paid. But webhooks can be delayed, retried, delivered out of order or blocked by your firewall during a deploy. If your only signal is the webhook, a missed delivery means a paid customer with an unpaid order.
Polling: slow but reliable
A scheduled job that asks the gateway for the status of every pending invoice catches anything the webhook missed. It is slower and costs API calls, so it should be a safety net, not the main path.
A minimal combined design
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.raw({ type: "application/json" }));
const SECRET = process.env.GATEWAY_WEBHOOK_SECRET;
function verify(req) {
const sig = req.get("X-Signature") || "";
const mac = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
return sig.length === mac.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac));
}
app.post("/webhooks/crypto", async (req, res) => {
if (!verify(req)) return res.status(401).end();
const evt = JSON.parse(req.body);
await markPaidIfConfirmed(evt.invoice_id, "webhook");
res.status(200).end(); // acknowledge quickly
});
async function pollPending() {
const pending = await db.invoices.findPending({ olderThanMinutes: 5 });
for (const inv of pending) {
const status = await gateway.getInvoice(inv.id);
if (status.state === "confirmed") await markPaidIfConfirmed(inv.id, "poll");
if (status.state === "expired") await db.invoices.expire(inv.id);
}
}
setInterval(pollPending, 2 * 60 * 1000);
async function markPaidIfConfirmed(id, source) {
// idempotent: only the first caller changes state
const updated = await db.invoices.updateWhere(
{ id, state: "pending" }, { state: "paid", paid_via: source });
if (updated) await fulfil(id);
}
The details that matter
- Idempotency. Both paths call the same function, and the conditional update ensures fulfilment happens once.
- Verify signatures on webhooks and re-fetch the invoice from the API before fulfilling high-value orders.
- Respond fast. Acknowledge the webhook, then do slow work in a queue.
- Poll only what is pending, with a back-off for invoices older than an hour.
-
Log the source (
webhookorpoll). If many payments are confirmed by polling, your webhook endpoint has a problem.
Gateways that offer an API, plugins and an SDK usually document both mechanisms; read the retry policy before you go live. For a broader look at choosing and integrating a gateway as an overseas merchant, see this crypto payment gateway guide.
Published by PandaWM, an overseas marketing team based in Hong Kong.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.