Node.js Crypto Payment API Integration with Express
A Node.js crypto payment API integration: create invoices with fetch, verify signed webhooks in Express and fulfil USDT and TRX orders safely, step by step.

A Node.js crypto payment API integration doesn't need an extra package. The MercanPay REST API speaks plain JSON, and the built-in fetch in Node 18+ is all you need. This guide walks through an Express app that creates an invoice, redirects the customer to the hosted payment page and fulfils the order from a signed webhook.
Is there a Node.js SDK?
No, there is no official Node.js library at the moment. Official clients exist for PHP (composer require mercanpay/mercanpay-php) and Python (pip install mercanpay). From Node.js you call the REST endpoints directly, which in practice means one small helper function.
You'll need:
- Node.js 18 or newer (for built-in
fetch) - an approved MercanPay merchant account and an API key with the
invoicesscope - the webhook signing secret (
whsec_…) from the Settings page of the panel
No account yet? Apply as a merchant first. The process is described in the MercanPay merchant application.
The flow at a glance
Before writing code, it pays to be clear about which job happens where. It prevents most of the bugs you'd otherwise hit later. A Node.js crypto payment integration has three separate parts:
- Starting the payment: when the customer confirms the cart, your server creates an invoice and sends the customer to the payment page. No money has moved yet; you've only opened a payment request.
- Payment notification: after the customer pays and the payment is final, MercanPay sends your server a webhook. That's the only reliable moment to fulfil the order.
- Customer feedback: the customer's browser returns to your thank-you page. That page informs; it never changes the order status.
Keeping these apart stops the classic mistake of fulfilling orders from the browser redirect. A browser can close, a connection can drop, or someone can edit the query string by hand. A webhook is a signed, retried, server-to-server notification.
Using the API key only on the server follows naturally. Browser JavaScript should never call the MercanPay API directly. The browser talks to your server, and your server talks to MercanPay with the key.
1. Write a tiny API helper
Keep credentials in environment variables:
MERCANPAY_API_KEY=mp_...
MERCANPAY_WEBHOOK_SECRET=whsec_...
Every request carries an Authorization: Bearer header. Error responses always have the shape { "error": { "code", "message" } }:
// mercanpay.js
const BASE = 'https://mercanpay.com';
export async function mp(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.MERCANPAY_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const err = new Error(data?.error?.message || `HTTP ${res.status}`);
err.status = res.status;
err.code = data?.error?.code || 'http_error';
throw err;
}
return data;
}
2. Create a crypto payment invoice from Node.js
When the customer clicks "Pay", your server calls POST /v1/invoices and redirects to the returned payment_url. Pricing in USD and letting the customer pick the coin is the most flexible option:
import express from 'express';
import { mp } from './mercanpay.js';
const app = express();
app.post('/checkout/:orderId', async (req, res) => {
const order = await findOrder(req.params.orderId); // your database
try {
const invoice = await mp('POST', '/v1/invoices', {
price_usd: order.total, // a string such as "49.90"
order_id: `order-${order.id}`,
description: order.title,
success_url: `https://myshop.com/order/${order.id}/thanks`,
return_url: 'https://myshop.com/cart',
metadata: { customer_id: order.customerId },
});
res.redirect(303, invoice.payment_url);
} catch (e) {
console.error('MercanPay', e.code, e.message);
res.status(502).send('The payment page could not be opened. Please try again.');
}
});
Details that matter:
- Amounts are strings. Send
"49.90", not49.9, so floating-point rounding can't break a payment. - For a fixed coin, send
tokenandamountinstead ofprice_usd:{ token: 'USDT', amount: '49.90' }. - To limit the choice, add
tokens: ['USDT']. expires_in_minutesaccepts 5 to 1440 minutes (24 hours).- The minimum invoice is $10. Network fees are included in the amount the customer pays.
Why USD pricing helps is covered in crypto payment exchange rate risk.
3. Safe retries with order_id
After a network timeout you may not know whether the invoice was created. A second request with the same order_id doesn't open a new invoice; it returns the existing one with 200. That makes retrying on 429 or 5xx safe:
async function createInvoiceWithRetry(body, tries = 3) {
for (let i = 0; ; i++) {
try {
return await mp('POST', '/v1/invoices', body);
} catch (e) {
const retriable = e.status === 429 || e.status >= 500 || !e.status;
if (!retriable || i >= tries - 1) throw e;
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
}
The default rate limit is 120 requests per minute per IP. More in order_id idempotency for invoices.
4. Verify the webhook in Express
Once a payment is final after 20 block confirmations, MercanPay sends a signed POST. The signature lives in X-MercanPay-Signature: t=…,v1=… and covers the raw body, so use express.raw() on this route, not express.json():
import crypto from 'node:crypto';
function verify(secret, rawBody, header, tolerance = 300) {
const sig = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(sig.t);
if (!sig.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const mac = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return mac.length === sig.v1.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig.v1));
}
app.post('/mercanpay/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = verify(process.env.MERCANPAY_WEBHOOK_SECRET, req.body.toString(), req.get('X-MercanPay-Signature') || '');
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer fast, do the work afterwards
if (event.type === 'invoice.paid' || event.type === 'invoice.overpaid') {
const inv = event.data.invoice;
await fulfilOnce(event.id, inv.order_id, inv.token, inv.paid_amount);
}
});
The theory is in how to verify webhook signatures, and every event type is listed in crypto payment webhook events.
Answer fast, expect repeats
If you don't return a 2xx within 10 seconds, the request is retried after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h and 24 h. The same event can arrive more than once, so store event.id in a unique column inside fulfilOnce. See webhook retries and idempotent handling.
5. Query the API on the thank-you page
After paying, the customer's browser lands on success_url with invoice_id, order_id and status appended. Those parameters can be forged, so read the real state from the API:
app.get('/order/:id/thanks', async (req, res) => {
const inv = await mp('GET', `/v1/invoices/by-order/order-${encodeURIComponent(req.params.id)}`);
const paid = inv.status === 'paid' || inv.status === 'overpaid';
res.send(paid ? 'Your payment is confirmed. Thank you!' : 'Your payment is being confirmed. You will get an email shortly.');
});
If the payment isn't final yet, a "being confirmed" message is enough. The webhook will fulfil the order anyway, so there's no need to make the customer refresh the page; a confirmation email is usually the better experience.
To cancel an unpaid invoice, call POST /v1/invoices/{id}/cancel. Only pending invoices that have received nothing can be cancelled.
Error codes at a glance
| HTTP | Code | Meaning |
|---|---|---|
| 400 | validation_error |
A field is wrong; the message says which |
| 401 | unauthorized |
The key is missing, wrong or revoked |
| 403 | forbidden |
The key lacks the scope for this call |
| 404 | not_found |
Not found, or it belongs to another merchant |
| 429 | — | Rate limited; wait briefly |
| 5xx | — | Temporary error; retry the same request |
Before you go live
- The API key lives only on the server and never reaches the browser.
- The webhook route uses
express.raw()and verifies the signature. - Every order has a unique
order_id. invoice.partialandinvoice.overpaidare handled.- You received a
test.pingvia "Send test" on the Webhooks page. - You tested the whole flow with a small real payment.
FAQ
Can I use TypeScript?
Yes. The code above ports directly. Define your own response types from the sample responses in the documentation.
Does this work with Next.js or Fastify?
It does. The one critical detail is verifying the signature against the raw body, before any JSON parsing. Check how your framework exposes it.
Can I send payouts from Node.js?
With a withdrawals-scoped key you can call POST /v1/withdrawals. For security, API withdrawals only go to saved addresses marked as trusted in the panel. See the crypto balance withdrawal process.
What if I use Python or PHP?
Then the official libraries save you time: Python crypto payment integration and PHP crypto payment integration.
Get started with MercanPay
A Node.js crypto payment API integration comes down to a handful of HTTP calls. Open your merchant account, create an API key and issue your first USDT invoice today.


