MercanPay Blog

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.

Published: November 1, 20267 min readTürkçe oku
Node.js Crypto Payment API Integration with Express

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 invoices scope
  • 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", not 49.9, so floating-point rounding can't break a payment.
  • For a fixed coin, send token and amount instead of price_usd: { token: 'USDT', amount: '49.90' }.
  • To limit the choice, add tokens: ['USDT'].
  • expires_in_minutes accepts 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.partial and invoice.overpaid are handled.
  • You received a test.ping via "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.

#Node.js#API#Webhook#USDT

Related posts

Start accepting crypto payments in minutes

Accept USDT and TRX (TRC20). Fees from 0.4%, a hosted payment page, API and webhooks.