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. If your site runs on WooCommerce or PrestaShop, or you'd rather not write code, this guide compares the 3 ways to accept crypto payments on your website.
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 CIGALPAY_API_KEY="your-api-key" # only for refunds (step 5)
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 { id, chargeLink } = await response.json();
order.checkoutId = id; // checked when the notification arrives
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);
// Anyone can create a checkout with your account ID and an order number.
// Only trust a payment for the checkout you created, at the order's price.
if (payment.checkoutId !== order.checkoutId) return res.sendStatus(200);
if (payment.checkoutCurrency !== order.currency || Math.abs(Number(payment.checkoutAmount) - order.amount) > 0.01) {
return res.sendStatus(200);
}
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';
order.paymentId = payment.id; // needed to refund it later
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:
-
Check the checkout, not just the order number. Your account ID isn't secret: it's in every checkout you create. Without the
checkoutIdcheck, someone could create their own checkout for your order number, pay $1, and get a signed notification for it. The signature proves the notification comes from CigalPay, not that it's for the checkout you made. -
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.
5. Refund an order
A crypto transfer can't be reversed, so a refund is a new payment to the customer. CigalPay asks the customer for their address and sends it; you only say which payment and how much. This call needs your API key (dashboard, Settings), on top of the account ID:
app.post('/orders/:orderId/refund', express.json(), async (req, res) => {
const order = orders.get(req.params.orderId);
if (!order?.paymentId) return res.sendStatus(404); // save payment.id from the notification
const response = await fetch('https://merchant-api.cigalpay.com/api/refunds', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'cigalpay-api-key': process.env.CIGALPAY_API_KEY },
body: JSON.stringify({
paymentId: order.paymentId,
checkoutAmount: req.body.amount, // in the order's USD / EUR, or `amount` in the coin paid
customerEmail: req.body.email, // gets the link to enter their address
reason: 'Returned item',
}),
});
const data = await response.json();
if (!response.ok) return res.status(response.status).send(data.error); // e.g. balance too low
res.json({ refundId: data.refund.id, customerLink: data.link });
});
The refund and its network fee come out of your CigalPay balance in the coin the customer paid. checkoutAmount is converted at the payment's rate, so the customer gets back the same share of what they sent.
Prefer an SDK?
Everything above is plain HTTP so you can see what happens. In a real app, the official SDK (cigalpay-js-sdk, no dependencies, TypeScript types included) does the same in a few lines:
import CigalPay from 'cigalpay-js-sdk';
const cigalpay = new CigalPay({ accountId: process.env.CIGALPAY_ACCOUNT_ID, apiKey: process.env.CIGALPAY_API_KEY });
const { id, chargeLink } = await cigalpay.createCheckout({
amount: 49.9, currency: 'USD', orderId: '1042', description: 'Order #1042',
returnUrl: `${PUBLIC_URL}/thank-you`, notifyUrl: `${PUBLIC_URL}/cigalpay/ipn`,
});
CigalPay.verifyWebhook(req.body, req.get('x-cigalpay-sig'), process.env.CIGALPAY_IPN_SECRET); // true or false
await cigalpay.createRefund({ paymentId, checkoutAmount: 20, customerEmail: 'customer@example.com' });
On WooCommerce or PrestaShop, you don't need code at all: the official plugins do all of this, refunds included.
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 and checkoutId, update the order from status and extraId
|
POST /orders/:orderId/refund |
Optional: refund with POST /api/refunds and your API key |
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
- SDKs: Node.js and PHP, plus WooCommerce and PrestaShop plugins.
- Pricing: 1% per transaction with no monthly fee, or 0.5% on the Pro plan.
Questions about confirmations, underpayments or wrong-network deposits? Ask in the comments, happy to help.
Top comments (0)