Integrating with NOWPayments
NOWPayments is a hosted payment gateway that handles address generation, blockchain monitoring, and currency conversion for you. Proper integration takes 2–3 days, but there are several pitfalls: IPN signature verification, partial payment handling, and idempotent webhook processing.
Payment Flow
1. Your backend → POST /v1/payment → NOWPayments
Receive: payment_id, pay_address, pay_amount, expiration_estimate_date
2. Show client QR-code and address to pay
3. NOWPayments monitors blockchain
4. NOWPayments → IPN Webhook → Your backend
payment_status: waiting → confirming → finished/failed/expired
5. Your backend verifies signature, updates order
Creating a Payment
interface CreatePaymentRequest {
price_amount: number; // sum in price_currency
price_currency: string; // 'usd', 'eur'
pay_currency: string; // 'btc', 'eth', 'usdterc20', 'usdttrc20'
order_id: string; // your internal ID
order_description?: string;
ipn_callback_url: string; // webhook URL
success_url?: string;
cancel_url?: string;
}
async function createPayment(
orderData: CreatePaymentRequest
): Promise<NOWPaymentsPayment> {
const response = await fetch('https://api.nowpayments.io/v1/payment', {
method: 'POST',
headers: {
'x-api-key': process.env.NOWPAYMENTS_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`NOWPayments error: ${error.message}`);
}
return response.json();
}
Pay attention to pay_currency — it's not just coin name, but specific coin in specific network. usdterc20 is USDT on Ethereum, usdttrc20 is USDT on TRON, usdtbsc is on BNB Chain. Always get current pay_currency list from /v1/currencies, don't hardcode.
Verifying IPN Signature — Critical
NOWPayments signs each webhook with HMAC-SHA512 using your IPN key (separate from API key). Without signature verification, attacker can send fake finished status and get free goods.
import * as crypto from 'crypto';
function verifyIPNSignature(
payload: string, // raw request body, not parsed
receivedSignature: string,
ipnSecret: string
): boolean {
const hmac = crypto.createHmac('sha512', ipnSecret);
hmac.update(payload);
const computedSignature = hmac.digest('hex');
// Constant-time comparison — protection from timing attacks
return crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(receivedSignature)
);
}
// Express middleware
app.post('/webhook/nowpayments',
express.raw({ type: 'application/json' }), // raw body!
(req, res) => {
const signature = req.headers['x-nowpayments-sig'] as string;
if (!verifyIPNSignature(
req.body.toString(),
signature,
process.env.NOWPAYMENTS_IPN_SECRET!
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payment = JSON.parse(req.body.toString());
handlePaymentUpdate(payment);
res.status(200).json({ ok: true });
}
);
Important: for HMAC verification you need raw body. If middleware express.json() already parsed — signature won't match due to JSON serialization differences. Use express.raw() for webhook endpoint.
Statuses and Idempotent Processing
NOWPayments sends webhook on each status change. Same statuses may arrive multiple times (retry on your server unavailability).
type PaymentStatus =
| 'waiting' // waiting for payment
| 'confirming' // transaction found, waiting for confirmations
| 'confirmed' // confirmed
| 'sending' // NOWPayments converts and sends
| 'partially_paid' // incomplete sum received
| 'finished' // successfully completed
| 'failed' // error
| 'refunded' // refund
| 'expired'; // wait time expired
async function handlePaymentUpdate(data: IPNPayload): Promise<void> {
// Idempotency: check if already processed
const existing = await db.query(
'SELECT status FROM payments WHERE nowpayments_id = $1',
[data.payment_id]
);
if (existing.rows[0]?.status === 'finished') {
return; // Already processed, ignore
}
await db.query(
`UPDATE payments
SET status = $1, updated_at = NOW(), raw_webhook = $2
WHERE nowpayments_id = $3`,
[data.payment_status, JSON.stringify(data), data.payment_id]
);
if (data.payment_status === 'finished') {
await fulfillOrder(data.order_id);
}
if (data.payment_status === 'partially_paid') {
await notifyPartialPayment(data.order_id, data.actually_paid, data.pay_amount);
}
}
Sandbox for Testing
NOWPayments provides sandbox: https://api-sandbox.nowpayments.io. Separate API keys, test transactions don't go to real networks. For local webhook testing — ngrok or Cloudflare Tunnel for public URL.
# Test via curl
curl -X POST https://api-sandbox.nowpayments.io/v1/payment \
-H "x-api-key: YOUR_SANDBOX_KEY" \
-H "Content-Type: application/json" \
-d '{"price_amount":10,"price_currency":"usd","pay_currency":"btc","order_id":"test-001","ipn_callback_url":"https://your-ngrok-url/webhook/nowpayments"}'
What's Worth Implementing Additionally
-
Polling as fallback: if webhook doesn't arrive within 30 minutes after payment creation — query
/v1/payment/{id}yourself -
Storing
payment_idfrom NOWPayments in your orders table — needed for reconciliation - Log all raw webhook payloads — helps with debugging and disputes
-
Alert on
partially_paid— requires manual decision: accept, request top-up, or refund







