DEV Community

Cover image for How to accept crypto payments in Node.js (Express) in 5 minutes
CigalPay
CigalPay

Posted on Edited on

How to accept crypto payments in Node.js (Express) in 5 minutes

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:

  1. Create a payment and send the customer to a hosted checkout.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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 checkoutId check, 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.timingSafeEqual avoids leaking how much of the signature matched.
  • Idempotency. The same status can be delivered more than once; check the current order state before acting.
  • CONFIRMING is 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, swapFromNetwork and swapFromAmount, 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...
Enter fullscreen mode Exit fullscreen mode

Set PUBLIC_URL to the tunnel's address, restart, then:

curl -i -X POST "$PUBLIC_URL/checkout/1042"
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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)