Adding crypto as a payment option sounds like a lot of work: running nodes, generating addresses, watching the
blockchain, handling confirmations, converting prices. With a payment gateway, it comes down to two endpoints on your
server:
- Create a payment and send the customer to a hosted checkout.
- Receive a signed notification when the payment confirms, and mark the order as paid.
In this tutorial we build both with Node.js and Express, using CigalPay
(disclosure: I'm part of the team building it). It's plain HTTP, so the same flow works in any language.
What you need: Node.js 18 or later (for the built-in fetch), and a CigalPay account. Your account ID and
your notification signing secret are in the merchant dashboard, under Settings.
1. Set up the project
mkdir crypto-checkout && cd crypto-checkout
npm init -y
npm pkg set type=module
npm install express
Put your credentials in environment variables, never in the code:
export CIGALPAY_ACCOUNT_ID="your-account-id"
export CIGALPAY_IPN_SECRET="your-notification-signing-secret"
export PUBLIC_URL="https://your-app.com" # where CigalPay can reach your server
2. Create a payment
When the customer clicks "Pay with crypto", your server creates a payment and redirects them to the checkout.
The price is in USD or EUR; the customer chooses the coin on the checkout, and the rate is locked while they pay.
// server.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const { CIGALPAY_ACCOUNT_ID, CIGALPAY_IPN_SECRET, PUBLIC_URL } = process.env;
// A tiny in-memory "database" for the demo.
const orders = new Map();
app.post('/checkout/:orderId', async (req, res) => {
const orderId = req.params.orderId;
const order = { id: orderId, amount: 49.9, currency: 'USD', status: 'pending' };
orders.set(orderId, order);
const response = await fetch('https://coins-api.cigalpay.com/api/pre-invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: CIGALPAY_ACCOUNT_ID,
amount: order.amount,
currency: order.currency, // USD or EUR
description: `Order #${orderId}`, // shown to the customer
extraId: orderId, // your reference, sent back in the notification
lang: 'en', // checkout language: en, fr, es, de
callback: `${PUBLIC_URL}/thank-you`, // where the customer returns
ipn: `${PUBLIC_URL}/cigalpay/ipn`, // where the signed notification goes
}),
});
if (!response.ok) return res.status(502).send('Could not create the payment');
const { chargeLink } = await response.json();
res.redirect(303, chargeLink);
});
That's the whole integration on the "pay" side. The customer lands on the checkout, picks a coin (Bitcoin, USDT,
Ethereum and many others), scans the QR code or copies the address, and pays.
3. Verify the signed notification
When the payment status changes, CigalPay sends a POST with a JSON body to your ipn URL. The x-cigalpay-sig
header holds an HMAC-SHA512 signature of the body, computed with your signing secret over the JSON with its
top-level keys sorted.
Never mark an order as paid from the customer's redirect to /thank-you: anyone can open that URL. Only trust the
notification, and only after checking its signature.
function isSignatureValid(body, received) {
const expected = crypto
.createHmac('sha512', CIGALPAY_IPN_SECRET)
.update(JSON.stringify(body, Object.keys(body).sort()))
.digest('hex');
return (
typeof received === 'string' &&
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
);
}
app.post('/cigalpay/ipn', express.json(), (req, res) => {
const payment = req.body;
if (!isSignatureValid(payment, req.get('x-cigalpay-sig'))) return res.sendStatus(401);
const order = orders.get(payment.extraId);
if (!order) return res.sendStatus(404);
switch (payment.status) {
case 'CONFIRMING':
// Seen on the network, not final yet: show "payment pending", don't ship.
order.status = 'confirming';
break;
case 'CONFIRMED':
case 'FINISHED':
// Final: fulfil the order. Notifications can arrive more than once,
// so make this step idempotent.
if (order.status !== 'paid') {
order.status = 'paid';
console.log(`Order ${order.id} paid`);
}
break;
case 'PARTIALLY_PAID':
case 'EXPIRED':
case 'FAILED':
order.status = payment.status.toLowerCase();
break;
}
res.sendStatus(200);
});
app.get('/thank-you', (_req, res) => res.send('Thanks! Your payment is being confirmed.'));
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
A few details worth knowing:
-
Constant-time comparison.
crypto.timingSafeEqualavoids leaking how much of the signature matched. - Idempotency. The same status can be delivered more than once; check the current order state before acting.
-
CONFIRMINGis not paid. The transaction is on the network but not confirmed yet. Ship only on a final status. -
Other coins. If the customer paid with a coin that was converted at checkout, the notification also contains
swapFromCurrency,swapFromNetworkandswapFromAmount, so you can show what they actually sent.
4. Test it end to end
Your notification URL must be reachable from the internet. Locally, a tunnel does the job:
node server.js
# in another terminal
npx localtunnel --port 3000 # or ngrok, cloudflared...
Set PUBLIC_URL to the tunnel's address, restart, then:
curl -i -X POST "$PUBLIC_URL/checkout/1042"
Open the Location URL from the response, pay a small amount (a real $1 payment is the best test), and watch your
terminal: Order 1042 paid.
Wrapping up
The whole integration is two routes:
| Route | Job |
|---|---|
POST /checkout/:orderId |
Create the payment with POST /api/pre-invoice, redirect to chargeLink
|
POST /cigalpay/ipn |
Verify x-cigalpay-sig, update the order from status and extraId
|
No nodes, no wallets to run, no blockchain polling. If you'd rather not write code at all, the dashboard can also
create a payment link you can send by message or show as a QR code.
- Docs and API reference: cigalpay.com/developers
- Pricing: 1% per transaction, no monthly fee.
Questions about confirmations, underpayments or wrong-network deposits? Ask in the comments, happy to help.
Top comments (0)