Developers
How to Add a Crypto Payment Gateway to Your Website
Add a crypto payment gateway for website checkout: create invoices on your server, redirect to a hosted page, verify signed webhooks and reconcile events.
Flexrix Pay··6 min read
Adding a crypto payment gateway for website checkout comes down to four pieces: your server creates an invoice for each order, the customer pays on a hosted (or custom) payment page, the gateway sends a signed webhook when the payment is confirmed, and your server marks the order paid. You never need to run a blockchain node or hold private keys. This guide shows the full flow with working Node.js code against the Flexrix Pay REST API.
Three ways to take crypto payments on a website
Pick the level of integration that fits your site. You can start simple and move up later.
| Option | Code needed | Best for |
|---|---|---|
| Payment link button | None | Small catalogs, services, one-off invoices |
| Server-created invoice + hosted page | Small back-end change | Most online stores and SaaS |
| Server-created invoice + your own page | Front-end and back-end | Teams that want full control of the UI |
Payment link button. Create a payment link in the dashboard and put it behind a "Pay with crypto" button. It works, but every order needs a new link, so it does not scale to a real cart.
Invoice + hosted page. Your back end calls the API when the customer clicks "Pay with crypto", gets back a checkout_url, and redirects. The hosted page on pay.flexrixpay.com handles coin selection, QR codes, wallet buttons and status updates. This is the recommended path and the one this guide follows.
Invoice + your own page. Same invoice, but you render the payment screen yourself using the public GET /v1/checkout/{token} endpoint, and let the customer pick a coin with POST /v1/checkout/{token}/select.
There are no ready-made WooCommerce or Shopify plugins, so on those platforms you use payment links or a small custom integration with the same API calls shown here.
How a crypto payment gateway for website checkout works
- The customer clicks "Pay with crypto" on your checkout.
- Your server creates an invoice with your order id as
foreign_idand a fiat price. - Your server redirects the customer to the invoice's
checkout_url. - The customer picks a coin and network. The rate, crypto amount and payment address are locked at that moment.
- The customer pays from a wallet. The page shows the payment as soon as it appears on-chain.
- After confirmations and screening, the invoice becomes
paid,underpaidoroverpaid, and a signed webhook reaches your server. - Your server verifies the signature, updates the order, and the customer returns to your
return_url.
The webhook, not the redirect, is your source of truth. A customer can close the tab before the redirect happens.
Crypto payment gateway integration, step 1: API key and signing
In the dashboard, create an API key with only the scopes you need. For checkout, that is payments (create invoices and webhook endpoints) and read. Keep the secret on your server.
Every request is signed with HMAC-SHA256. You send four headers: X-FP-Key, X-FP-Timestamp, X-FP-Nonce and X-FP-Signature. The signature covers a canonical string built from a version tag, the timestamp, a one-time nonce, the HTTP method, the path with query, and the SHA-256 hash of the raw body. Because the timestamp and nonce are signed, a captured request cannot be replayed.
import crypto from 'node:crypto';
export function signed(keyId, secret, method, pathWithQuery, body = '') {
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(18).toString('base64url');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const canonical = ['v1', ts, nonce, method.toUpperCase(), pathWithQuery, bodyHash].join('\n');
const sig = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
return { 'X-FP-Key': keyId, 'X-FP-Timestamp': ts, 'X-FP-Nonce': nonce, 'X-FP-Signature': sig };
}
export async function fp(method, path, payload) {
const body = payload ? JSON.stringify(payload) : '';
const res = await fetch('https://api.flexrixpay.com' + path, {
method,
headers: {
'Content-Type': 'application/json',
...signed(process.env.FP_KEY, process.env.FP_SECRET, method, path, body),
},
body: body || undefined,
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error?.message), { code: data.error?.code });
return data;
}If a request fails with an authentication error, the dashboard request log shows which part of the signature differed, and the signature checker lets you test your canonical string without revealing a valid signature.
Step 2: Create an invoice when the customer checks out
Price the invoice in your store currency and let the customer choose the coin. Restrict the choice with allowed_assets if you only want a few options. Amounts are always decimal strings, never floats.
import express from 'express';
import { fp } from './fp.js';
const app = express();
app.post('/checkout/crypto', express.json(), async (req, res) => {
const order = await db.orders.get(req.body.orderId);
const { invoice } = await fp('POST', '/v1/invoices', {
foreign_id: order.id, // your order id
price_amount: order.total, // e.g. "49.00"
price_currency: order.currency, // e.g. "EUR"
allowed_assets: ['USDT-TRON', 'USDT-BSC', 'USDC-SOL', 'BTC'],
expires_in: 3600, // 300 to 3600 seconds
return_url: `https://shop.example.com/orders/${order.id}`,
metadata: { cart: order.cartId },
});
await db.orders.update(order.id, { invoiceId: invoice.id, status: 'awaiting_payment' });
res.json({ redirect: invoice.checkout_url });
});Two details make this safe to retry. First, foreign_id is idempotent: sending the same order id with the same content returns the existing invoice (created: false) rather than a duplicate, and different content returns a 409 FOREIGN_ID_CONFLICT error. Second, you can also send an Idempotency-Key header on any POST.
Invoices are valid for 5 to 60 minutes (one hour by default). A short window keeps the locked fiat rate fair for both sides.
Step 3: Send the customer to the payment page
Redirect the browser to checkout_url. The hosted page is mobile-first and available in 20 languages. Customers can pay in one tap from MetaMask, Trust Wallet, Phantom or TronLink, connect 500+ wallets through WalletConnect, or scan a QR code with a payment URI. It also explains clearly what happens if they pay too little or too late.
If you want the page to carry your brand, white label lets you show your own name, logo and colour on it.
Step 4: Receive and verify webhooks
Register an HTTPS endpoint with POST /v1/webhook-endpoints (or in the dashboard) and subscribe to invoice events: invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired and invoice.payment_received.
Webhooks follow the Standard Webhooks specification. Each delivery carries webhook-id, webhook-timestamp and webhook-signature headers. You verify the signature over the raw body, before parsing JSON.
function verifyWebhook(secret, headers, rawBody) {
const id = headers['webhook-id'], ts = Number(headers['webhook-timestamp']);
if (!id || !Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const want = 'v1,' + crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
return (headers['webhook-signature'] || '').split(' ').some((s) =>
s.length === want.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(want)));
}
app.post('/webhooks/flexrixpay', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyWebhook(process.env.FP_WHSEC, req.headers, raw)) return res.status(400).end();
const event = JSON.parse(raw);
res.status(200).end(); // acknowledge fast, process after
if (await db.events.seen(event.id)) return; // de-duplicate by webhook-id
await db.events.store(event);
const { foreign_id: orderId, status } = event.data;
if (event.type === 'invoice.paid' || event.type === 'invoice.overpaid') {
await db.orders.update(orderId, { status: 'paid', paymentStatus: status });
} else if (event.type === 'invoice.underpaid') {
await db.orders.update(orderId, { status: 'needs_review', paymentStatus: status });
} else if (event.type === 'invoice.expired') {
await db.orders.update(orderId, { status: 'expired' });
}
});Points that matter in production:
- Respond within 10 seconds with a 2xx, then process asynchronously. Failed deliveries are retried with back-off over about three days.
- De-duplicate using the message id. Retries can deliver the same event more than once.
- Do not trust order. Delivery order is not guaranteed. Each event carries a
sequencenumber that only goes up.
The dashboard webhook tester checks your endpoint's DNS, TLS, status code and latency, and you can send sample events before going live.
Step 5: Handle underpaid, overpaid and late payments
Every on-chain payment above the network minimum to an invoice address is credited to your balance once confirmed and screened, whatever the invoice status. The status tells you what happened; you decide the business outcome.
| Invoice status | What it means | Typical action |
|---|---|---|
paid | Exact amount on time | Fulfil the order |
overpaid | More than requested | Fulfil, refund the extra by payout if asked |
underpaid | Less than requested | Ask for the rest, or refund |
expired | No full payment in time | Cancel or offer a new invoice |
A payment that arrives after expiry is reported in late_received and in an invoice.payment_received event with late: true. Whether a payment was on time is decided by block time, not server time. Refunds are made as a payout; there are no chargebacks.
Step 6: Reconcile so nothing is missed
Webhooks are fast, but your server may be down when one arrives. Run a small job that reads GET /v1/events with after set to the last sequence you processed, and applies any events you have not processed. For a single order, GET /v1/invoices/{id} returns the current status.
async function reconcile() {
let after = await db.cursor.get('fp_events') || '0';
for (;;) {
const { events, next_after } = await fp('GET', `/v1/events?after=${after}&limit=100`);
for (const e of events) {
await applyEvent(e); // same idempotent handler as the webhook
await db.cursor.set('fp_events', String(e.sequence));
}
if (!next_after) break;
after = next_after;
}
}Step 7: Test before you go live
- Create a small invoice and pay it yourself on a low-fee network.
- Pay slightly less once, to see the underpaid path end to end.
- Let one invoice expire.
- Stop your webhook endpoint briefly and confirm your reconcile job catches up.
- Look up your test transactions in the blockchain explorer to see confirmations and fees.
FAQ
How do I accept crypto payments on my website without a plugin?
Create an invoice on your server through the REST API, redirect to its checkout_url, and mark the order paid when a signed webhook arrives. That is a few dozen lines of code in any language.
Can I build my own crypto checkout page?
Yes. Use GET /v1/checkout/{token} to read the public invoice data and POST /v1/checkout/{token}/select when the customer picks a coin. You then render the address, amount and QR code yourself.
Should I price in crypto or fiat?
Fiat is usually better for a website. The customer sees your normal price, picks a coin, and the rate is locked when they choose.
What if the customer closes the page after paying?
Nothing is lost. The payment is still detected on-chain and credited, and your webhook or reconcile job updates the order.
Key takeaways
- Create invoices on your server with your order id as
foreign_id; never in the browser. - Redirect to the hosted page for the fastest integration, or build your own with the checkout endpoints.
- Treat the verified webhook as the source of truth, de-duplicate it and acknowledge quickly.
- Plan for underpaid, overpaid, expired and late payments before launch.
- Reconcile with the events endpoint so a missed webhook never leaves an order stuck.
Try it with Flexrix Pay
You can create a free account, generate an API key and run a test checkout in an afternoon. Sign up here and keep the API reference open for exact fields, events and error codes.