# How to accept crypto payments in Node.js

> Accept Bitcoin and USDT in a Node.js app: sign API requests with node:crypto, create a payment, redirect to the payment page and verify the webhook in Express.

Source: https://cryptolike.net/guides/accept-crypto-payments-nodejs
Published: 2026-09-24 · Updated: 2026-10-04 · Author: CryptoLike team

A Node.js application accepts crypto with CryptoLike in three steps: sign each API request with `node:crypto`, create a payment for the order, and verify a signed webhook before marking the order paid. Node.js 18 or newer is enough — `fetch` and `crypto` are built in, so the integration has no dependencies beyond your web framework.

For a store on a CMS there is no code to write: see the [WooCommerce plugin](https://cryptolike.net/integrations/woocommerce) and the other [integrations](https://cryptolike.net/integrations). This guide is for your own backend: a SaaS, a marketplace or an app.

## What you need

A CryptoLike account, a server running Node.js 18+, and a public `https://` URL for webhooks. Keep the secrets in the environment: the examples read `CRYPTOLIKE_API_KEY_ID`, `CRYPTOLIKE_API_SECRET` and `CRYPTOLIKE_WEBHOOK_SECRET`, plus `CRYPTOLIKE_API_URL` for the test API URL (unset means production). Build and test with test keys and test networks first.

## 1. Create a test API key

In the cabinet open **Developers → API keys → Create key** with the permissions `create_invoice` and `read`. A `ck_test_` key works with test networks, where coins have no value. Test keys come from the test environment: CryptoLike support gives you access to its cabinet, where you create the same key, and its API URL (`CRYPTOLIKE_API_URL` in the examples). The production cabinet issues live keys only, and on mainnet a test key is refused with `401 auth.test_key`. The secret is shown once; store it in your secret manager or environment, never in front-end code.

## 2. Sign API requests with node:crypto

The signature is an HMAC-SHA256 of four lines — the Unix timestamp in seconds, the method, the path with the query, and the SHA-256 of the body — keyed with the API secret. This helper signs and sends a request:

```ts
import { createHash, createHmac } from "node:crypto";

export async function cryptolike(method: string, path: string, payload?: unknown, idempotencyKey?: string) {
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const ts = Math.floor(Date.now() / 1000).toString();
  const canonical = [ts, method.toUpperCase(), path, createHash("sha256").update(body).digest("hex")].join("\n");
  const res = await fetch(`${process.env.CRYPTOLIKE_API_URL ?? "https://api.cryptolike.net"}${path}`, {
    method,
    headers: {
      "X-API-Key": process.env.CRYPTOLIKE_API_KEY_ID!,
      "X-Timestamp": ts,
      "X-Signature": createHmac("sha256", process.env.CRYPTOLIKE_API_SECRET!).update(canonical).digest("hex"),
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: body || undefined,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error?.code ?? res.status}: ${data.error?.message ?? ""}`);
  return data;
}
```

Serialise the body once and send that same string: hashing one string and sending another is the most common cause of `401 auth.invalid_signature`. Check the helper against the reference vector in [API authentication](https://cryptolike.net/docs/auth).

## 3. Create the payment for an order

Create the payment on the server when the customer places the order. Price it in USD or EUR and let the customer choose the coin on the page, or pass a `coin_id` from `GET /v1/currencies`. Without `coin_id` the page lists the coins you offer for invoices, each with an approximate amount marked ≈; `allowed_coin_ids` (ids with `invoice_enabled: true` in the signed `GET /v1/currencies`) limits the list for this order. The order ID as the `Idempotency-Key` makes a retry return the same payment:

```ts
const invoice = await cryptolike("POST", "/v1/invoices", {
  amount: "49.90",
  currency: "USD",
  order_id: order.id,
  idempotency_by_order_id: true,
  return_url: `https://example.com/orders/${order.id}`,
}, `order-${order.id}`);

await orders.saveInvoiceId(order.id, invoice.id);
```

Keep amounts as strings end to end — `"49.90"`, never a JavaScript number.

## 4. Send the customer to the payment page

Redirect the browser to `invoice.checkout_url`, for example `res.redirect(303, invoice.checkout_url)` in Express. The payment page shows the amount, the network, the address with a QR code, the time left and the live status. To keep the customer in your own checkout, embed the page with the [checkout widget](https://cryptolike.net/docs/widget); the browser only ever sees the invoice ID.

## 5. Verify the webhook in Express

Add a webhook endpoint in **Developers → Webhooks** with the `invoice.*` events. Read the body raw — `express.json()` would re-serialise it and break the signature — verify, deduplicate by `event_id` and answer quickly:

```ts
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/webhooks/cryptolike", express.raw({ type: "application/json" }), async (req, res) => {
  const ts = String(req.header("X-Timestamp") ?? "");
  const got = Buffer.from(String(req.header("X-Signature") ?? ""));
  const secret = process.env.CRYPTOLIKE_WEBHOOK_SECRET ?? "";
  const want = Buffer.from("sha256=" + createHmac("sha256", secret).update(`${ts}.`).update(req.body).digest("hex"));
  const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  if (!secret || !fresh || got.length !== want.length || !timingSafeEqual(got, want)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  if (await events.firstTime(event.event_id)) {
    if (event.type === "invoice.paid") await orders.markPaid(event.data.invoice.order_id);
  }
  res.sendStatus(200);
});
```

Deliveries are at least once and can arrive out of order: trust the status inside `data`, not the order of arrival. The full procedure is in [verify crypto payment webhooks](https://cryptolike.net/guides/verify-crypto-payment-webhooks).

## 6. Test on a test network, then go live

Create a payment with the test key and pay it with test coins, on the network the page names. You should see `invoice.pending`, then `invoice.paid`. Handle `invoice.underpaid` (the customer may top up to the same address until the deadline), `invoice.expired` (offer a new payment) and `invoice.reverted` (hold fulfilment). Then switch to a live key and follow the go-live checklist in the [developer guide](https://cryptolike.net/docs).

## Common errors and fixes

| Error | What happened | What to do |
|---|---|---|
| `401 auth.invalid_signature` | The signed string differs from the request | Hash the exact body string you send; use seconds, not `Date.now()` milliseconds |
| `401 auth.replay` | Two identical requests in the same second | Wait for the next second and sign again |
| `401` on your webhook | The body was parsed before verification | Use `express.raw()` on the webhook route only |
| `409 conflict` | The order already has a payment for another amount | Use `details.invoice_id`; do not create a second payment |
| `422 no_allowed_coins` | None of `allowed_coin_ids` is offered for invoices in this currency | Send other ids, or omit the field to offer every coin |
| `429 rate_limited` | Over the key's request limit (600 per minute by default) | Wait `Retry-After` seconds |

## Prefer an SDK?

The official [TypeScript SDK](https://cryptolike.net/integrations/sdk-typescript) for Node.js 18+ wraps these calls with typed resources, retries on `429` and `5xx`, an `Idempotency-Key` on every `POST` and `verifyWebhook()`. It has no runtime dependencies and refuses to run in a browser, so the secret cannot end up in front-end code.

## FAQ

### Can I create CryptoLike payments from the browser?

No. The API secret signs requests as your account, so it must stay on the server. Your front end asks your backend to create the payment and receives only the invoice ID or the `checkout_url`. The official TypeScript SDK throws an error when it detects a browser for this reason, and the checkout widget reads only public payment data.

### Why does my webhook signature check fail in Express?

Almost always because `express.json()` parsed the body before the check: the signature covers the raw bytes, and re-serialised JSON differs. Mount `express.raw({ type: "application/json" })` on the webhook route, verify first, then parse. Also check that the secret is the one of that endpoint and that your server clock is synced.

### Do I need to poll the payment status?

No. Webhooks report every status change, signed and retried for about 31 hours if your server does not answer. Polling `GET /v1/invoices/{id}` every few seconds also works, for example on the page the customer returns to, but fulfil orders on the verified `invoice.paid` webhook, not on a browser event.
