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
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/invoice | Create a hosted payment invoice |
| GET | /v1/payment/{id} | Fetch invoice/payment status |
| GET | /v1/currencies | List available coins |
| GET | /v1/estimate | Preview 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 status | Port status |
|---|---|
waiting | pending |
confirming | pending |
confirmed | pending |
sending | pending |
partially_paid | partially_paid |
finished | finished |
failed | failed |
refunded | failed |
expired | expired |
| (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>¤cy_from=usd¤cy_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:
- Parse the raw request body as JSON.
- Recursively sort all object keys alphabetically (NOWPayments performs this sort on their side before signing).
- Re-serialize to JSON (compact, no whitespace).
- Compute
HMAC-SHA512(sortedJsonBody, NOWPAYMENTS_IPN_SECRET). - Compare hex digest (lowercase) with
x-nowpayments-sigheader usingtimingSafeEqual.
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/:
| File | Purpose |
|---|---|
invoice-create.response.json | Successful POST /invoice response |
payment-status.finished.json | GET /payment/{id} — finished status |
payment-status.partial.json | GET /payment/{id} — partially_paid |
currencies.response.json | GET /currencies response |
webhook.ipn-finished.json | IPN payload for a finished payment |
Environment Variables
| Variable | Default | Notes |
|---|---|---|
NOWPAYMENTS_API_KEY | — | Required when crypto enabled |
NOWPAYMENTS_IPN_SECRET | — | Required in production |
NOWPAYMENTS_API_URL | https://api.nowpayments.io/v1 | Override for sandbox |
RATE_LIMIT_NOWPAYMENTS_PER_MIN | 120 | IPN 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.