BitPay Integration
BitPay is one of the oldest crypto payment processors, launched in 2011. Today it supports BTC, ETH, USDC, USDT on several networks (Ethereum, Polygon, Arbitrum, Base) with automatic conversion to fiat. For businesses wanting to accept crypto without their own infrastructure and with ready legal documentation — a reasonable choice.
The API works via invoices (invoices): your backend creates an invoice on BitPay, gets a URL to redirect the user, BitPay accepts payment and notifies your webhook.
API Authentication
BitPay uses a non-standard authentication scheme based on ECDSA signatures. The client generates an ECDSA keypair, the public key is registered as a "token" on BitPay. Each request is signed with the private key.
const BitPaySDK = require('bitpay-sdk');
const fs = require('fs');
// Generate keys and get token (one time)
async function setupBitPay() {
const client = new BitPaySDK.Client(
null, // config file
BitPaySDK.Env.Prod, // or Env.Test for testnet
fs.readFileSync('./private.key', 'utf8') // ECDSA private key
);
// Token from BitPay Dashboard → API Tokens
await client.authorizeClient('your-pairing-code');
return client;
}
For most integrations it's easier to use the official BitPay SDK (Node.js, PHP, Python, Ruby, Java) — it encapsulates request signing.
Creating an Invoice
const BitPaySDK = require('bitpay-sdk');
async function createInvoice(orderId, amount, currency = 'USD') {
const invoice = new BitPaySDK.Models.Invoice(amount, currency);
invoice.orderId = orderId;
invoice.notificationUrl = `https://yourapp.com/webhooks/bitpay`;
invoice.redirectUrl = `https://yourapp.com/orders/${orderId}/success`;
invoice.closeUrl = `https://yourapp.com/orders/${orderId}/cancel`;
// Metadata for reconciliation
invoice.buyer = new BitPaySDK.Models.Buyer();
invoice.buyer.email = customerEmail;
// Optional: accept only specific coins
// invoice.paymentCurrencies = ['BTC', 'USDC'];
const created = await client.createInvoice(invoice);
return {
invoiceId: created.id,
paymentUrl: created.url, // redirect user
expirationTime: created.expirationTime
};
}
Invoice is valid for 15 minutes (by default) — user must pay within this period. Amount in USD is fixed at BitPay's rate at time of invoice creation.
Webhook Processing
BitPay sends IPN (Instant Payment Notification) to notificationUrl. Critically: verify invoice status via API, don't just trust webhook data.
const express = require('express');
const router = express.Router();
router.post('/webhooks/bitpay', async (req, res) => {
const { id: invoiceId, status } = req.body.data || {};
if (!invoiceId) {
return res.status(400).json({ error: 'Missing invoice ID' });
}
// IMPORTANT: verify via API, don't trust webhook body
const invoice = await client.getInvoice(invoiceId);
switch (invoice.status) {
case 'paid':
// Paid, but waiting for confirmations
await updateOrderStatus(invoice.orderId, 'paid_unconfirmed');
break;
case 'confirmed':
// Enough confirmations (usually 1 for most coins)
await updateOrderStatus(invoice.orderId, 'confirmed');
break;
case 'complete':
// All confirmations received, funds credited
await fulfillOrder(invoice.orderId);
break;
case 'expired':
await updateOrderStatus(invoice.orderId, 'expired');
break;
case 'invalid':
// Underpayment or other error
await handleInvalidPayment(invoice.orderId, invoice);
break;
}
res.json({ success: true });
});
Status chain: new → paid → confirmed → complete. Use confirmed or complete for fulfillment depending on risk tolerance. complete is safest but has longer delay.
Refunds
BitPay requires a return address — request it from the user at payment time or when initiating refund.
async function createRefund(invoiceId, amount, currency) {
const refund = new BitPaySDK.Models.Refund();
refund.invoiceId = invoiceId;
refund.amount = amount;
refund.currency = currency; // refund currency
const created = await client.createRefund(refund);
// BitPay will send email to user requesting address
return created;
}
Typical Integration Issues
Webhook doesn't arrive. BitPay requires HTTPS with valid certificate on notificationUrl. Localhost unreachable — for development use ngrok or BitPay Testnet with public URL.
Duplicate webhooks. BitPay can send multiple notifications about same status (retry on timeout). Use invoiceId as idempotency key: INSERT ... ON CONFLICT (invoice_id, status) DO NOTHING.
Partial payment. If user paid less — status is invalid. BitPay automatically returns underpayment if buyer email is present.
Timezone in expirationTime. Field returns as Unix timestamp in milliseconds. new Date(invoice.expirationTime) — don't forget ms, not seconds.
Testing
BitPay provides Testnet environment (BitPaySDK.Env.Test) with test Bitcoin. Create invoice, pay with testnet wallet — whole flow without real money. Pairing code for test environment created separately in dashboard.
Integration Process
Registration → API token creation → SDK integration in backend → webhook endpoint → Testnet testing → production pairing.
Timeline 2-3 days: 1 day on SDK integration and invoice creation, 1 day on webhook + status machine, 1 day on testing and edge cases (expired invoice, partial payment, webhook retry).







