Skip to main content

NOWPayments Adapter Reference

Technical reference for the createNowPaymentsAdapter implementation at src/adapters/nowpayments.adapter.ts.


Overview

The NOWPayments adapter implements CryptoPaymentPort (defined at src/types/crypto-payment.types.ts) using raw HTTPS to the NOWPayments REST API. No npm SDK is used — the official SDK is outdated and unmaintained.

Authentication: x-api-key header with the API key.


Port Contract

interface CryptoPaymentPort {
createInvoice(input: CreateInvoiceInput): Promise<InvoiceResult>
getInvoice(invoiceId: string): Promise<InvoiceStatus>
listAvailableCurrencies(): Promise<CryptoCoin[]>
estimatePayAmount(input: EstimateInput): Promise<EstimateResult>
}

Full type definitions: src/types/crypto-payment.types.ts


API Endpoints Used

MethodPathPurpose
POST/v1/invoiceCreate a hosted payment invoice
GET/v1/payment/{id}Fetch invoice/payment status
GET/v1/currenciesList available coins
GET/v1/estimatePreview crypto pay amount

Base URL: https://api.nowpayments.io/v1 (production) or https://api-sandbox.nowpayments.io/v1 (sandbox).


createInvoice

Maps amountUsdCents to a USD decimal string (e.g. 999 → "9.99") and posts to /v1/invoice.

Request body:

{
"price_amount": "9.99",
"price_currency": "usd",
"pay_currency": "btc",
"order_id": "<orderRef>",
"ipn_callback_url": "<callbackUrl>",
"success_url": "<successUrl>",
"cancel_url": "<cancelUrl>"
}

Response fields used: id, invoice_url, pay_address, pay_amount, expiration_estimate_date.

If expiration_estimate_date is absent or unparseable, expiry defaults to 20 minutes from now.


getInvoice

Calls GET /v1/payment/{invoiceId}. The invoiceId passed here is the id returned by createInvoice (the NOWPayments payment_id).

Status mapping:

NOWPayments statusPort status
waitingpending
confirmingpending
confirmedpending
sendingpending
partially_paidpartially_paid
finishedfinished
failedfailed
refundedfailed
expiredexpired
(anything else)pending

Zero-cached. Every call hits the API.


listAvailableCurrencies

Calls GET /v1/currencies and filters the response to the CryptoCoin set supported by this adapter (btc, xrp, usdttrc20, usdterc20, eth, ltc, doge, bnb, matic).

Cached in Valkey at key nowpayments:currencies:v1 for 24 hours (86400 seconds). Use redis-cli DEL nowpayments:currencies:v1 to force a refresh.


estimatePayAmount

Calls GET /v1/estimate?amount=<usd>&currency_from=usd&currency_to=<coin>. Returns payAmount (crypto decimal string) and rate (USD exchange rate).

Not cached.


Webhook Signature Verification

The IPN webhook endpoint (src/endpoints/nowpayments-webhook.endpoint.ts) verifies each incoming IPN using HMAC-SHA512.

Algorithm:

  1. Parse the raw request body as JSON.
  2. Recursively sort all object keys alphabetically (NOWPayments performs this sort on their side before signing).
  3. Re-serialize to JSON (compact, no whitespace).
  4. Compute HMAC-SHA512(sortedJsonBody, NOWPAYMENTS_IPN_SECRET).
  5. Compare hex digest (lowercase) with x-nowpayments-sig header using timingSafeEqual.

Sample HMAC-SHA512 verification snippet:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyIpnSignature(rawBody: string, sig: string, secret: string): boolean {
const sorted = JSON.stringify(sortKeys(JSON.parse(rawBody)))
const expected = createHmac('sha512', secret).update(sorted, 'utf8').digest('hex')
const received = sig.trim().toLowerCase()
if (received.length !== expected.length) return false
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'))
}

function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys)
if (value !== null && typeof value === 'object') {
const obj = value as Record<string, unknown>
return Object.keys(obj).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = sortKeys(obj[k])
return acc
}, {})
}
return value
}

Fail-closed policy: If NOWPAYMENTS_IPN_SECRET is not set and IS_PRODUCTION=true, the endpoint returns HTTP 500 and rejects all requests. Never deploy to production without the IPN secret.


Sample: Invoice Creation → Status Round-Trip

// --- Create invoice ---
const invoice = await cryptoPaymentPort.createInvoice({
amountUsdCents: 1999,
currency: 'btc',
orderRef: 'order-abc123',
callbackUrl: 'https://api.example.com/api/nowpayments/webhook',
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
})
// invoice.invoiceId = "4680658748"
// invoice.paymentUrl = "https://nowpayments.io/payment/?iid=4680658748"
// invoice.paymentAddress = "bc1q..."
// invoice.payAmount = "0.000516"
// invoice.expiresAt = Date (20 min from now)

// Redirect customer to invoice.paymentUrl

// --- Poll status (or rely on webhook) ---
const status = await cryptoPaymentPort.getInvoice(invoice.invoiceId)
// status.status = 'pending' | 'finished' | ...
// status.paidAmount = "0.000516"

Fixture Files

Test fixtures live in src/adapters/__fixtures__/nowpayments/:

FilePurpose
invoice-create.response.jsonSuccessful POST /invoice response
payment-status.finished.jsonGET /payment/{id}finished status
payment-status.partial.jsonGET /payment/{id}partially_paid
currencies.response.jsonGET /currencies response
webhook.ipn-finished.jsonIPN payload for a finished payment

Environment Variables

VariableDefaultNotes
NOWPAYMENTS_API_KEYRequired when crypto enabled
NOWPAYMENTS_IPN_SECRETRequired in production
NOWPAYMENTS_API_URLhttps://api.nowpayments.io/v1Override for sandbox
RATE_LIMIT_NOWPAYMENTS_PER_MIN120IPN endpoint rate limit

Error Handling

All API errors throw with the pattern:

NOWPayments <METHOD> <path> failed: <message> (HTTP <status>)

The OutboxService.classifyError heuristic treats 4xx (except 429) as dead (no retry) and 5xx/network errors as retry. NOWPayments retries IPN delivery automatically on 5xx responses from Ledgerline.


Sandbox Testing

# Set env vars
export NOWPAYMENTS_API_URL=https://api-sandbox.nowpayments.io/v1
export NOWPAYMENTS_API_KEY=<sandbox-key>

# Run skipped integration tests
pnpm vitest run src/adapters/nowpayments.adapter.spec.ts

The integration test is guarded by .skip and must be run manually against a real sandbox key.