Bill payment API integration with Node.js
Build a Node.js server integration for RizPay with timeouts, sandbox credentials, stored order references and explicit transaction status handling.
Already registered? Open API settings and follow the business-access steps. Sandbox purchases do not deliver real services.
Reviewed
Node.js 20 or newer provides fetch and AbortSignal.timeout. Keep this module in your server code. In Next.js, import it only from server actions, route handlers or other server-only modules; never from a component marked use client.
Start with an approved order
Import RizPaySandbox, call listAirtime and select an actual returned product. A queue worker can call purchaseAirtime with an already authorized and persisted order. Do not generate a new reference when the worker retries.
Create sandbox access in API settings after completing the account's business-access steps. For this airtime walkthrough, the key needs view_products, purchase_airtime and read_transactions. Keep the secret in RIZPAY_API_KEY on the server. The client below deliberately accepts only sandbox keys and uses a fixed sandbox URL.
The server client
import process from "node:process";
// Node.js 20+. Run on your server. This client only contacts the sandbox.
const BASE_URL = "https://my.rizpay.app/api/partners/sandbox/v1";
const REFERENCE = /^\d{10}[A-Za-z0-9]{6}$/;
export class RizPaySandbox {
constructor(key = process.env.RIZPAY_API_KEY) {
if (!key?.startsWith("sk_test_"))
throw new Error("A sandbox key is required.");
this.key = key;
}
async request(path, body) {
let response;
try {
response = await fetch(BASE_URL + path, {
method: body ? "POST" : "GET",
redirect: "error",
headers: {
Authorization: `Bearer ${this.key}`,
"Content-Type": "application/json",
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(20000),
});
} catch {
throw new Error(
"No confirmed response. Reconcile the stored order before retrying."
);
}
let json;
try {
json = await response.json();
} catch {
throw new Error("Unrecognized response. Check the existing order.");
}
if (!response.ok) {
const code = json?.error?.code;
const error = new Error(
code === "DUPLICATE_REFERENCE"
? "Reference already used. Retrieve the existing transaction."
: "Request was not accepted. Review the order and API settings."
);
error.code = /^[A-Z_]{3,60}$/.test(code ?? "") ? code : "API_ERROR";
error.status = response.status;
throw error;
}
return json;
}
listAirtime(network = "MTN", page = 1) {
if (!["MTN", "AIRTEL", "GLO", "9MOBILE"].includes(network))
throw new Error("Choose a supported network.");
if (!Number.isSafeInteger(page) || page < 1)
throw new Error("Invalid page.");
return this.request(`/products/airtimes?network=${network}&page=${page}`);
}
purchaseAirtime(order) {
if (!REFERENCE.test(order.external_reference ?? ""))
throw new Error("Use the reference stored with your order.");
if (!/^prd_\d+$/.test(order.product_id ?? ""))
throw new Error("Select a product from the catalogue.");
if (!["MTN", "AIRTEL", "GLO", "9MOBILE"].includes(order.network))
throw new Error("Confirm the selected network.");
if (!/^0[789]\d{9}$/.test(order.phone_number ?? ""))
throw new Error("Check the recipient number.");
if (typeof order.amount !== "string" || !/^\d+\.\d{2}$/.test(order.amount))
throw new Error("Use a decimal amount string.");
return this.request("/purchases", {
product_id: order.product_id,
network: order.network,
phone_number: order.phone_number,
amount: order.amount,
external_reference: order.external_reference,
});
}
findOrder(reference) {
if (!REFERENCE.test(reference))
throw new Error("Invalid stored reference.");
return this.request(`/account/transactions/${reference}`);
}
purchaseStatus(id) {
if (!/^txn_[A-Za-z0-9-]+$/.test(id))
throw new Error("Invalid transaction identifier.");
return this.request(`/purchases/${id}`);
}
}
Use the catalogue before purchasing
Call the catalogue method and read the product array in data. Use an ID from your own result, confirm its network and read its price limits. Keep product IDs as strings and use decimal strings for money. The client checks the input format; your application must still enforce the selected product's minimum and maximum amounts and its own customer authorization rules.
Use phone number 08011111111 for a configured successful sandbox airtime scenario. For pending scenarios, use 08022222222 or 08033333333. These are simulator inputs, not recipients for real top-ups. An arbitrary phone number does not necessarily produce a successful sandbox result.
Persist before sending
Generate the external reference once when you save the order. Follow RizPay's documented 16-character format: ten timestamp digits followed by six alphanumeric characters. Add a unique constraint for that reference in your own database. Pass the same stored value to the purchase method.
These methods do not automatically retry a purchase. If the response is lost, use the reference lookup method and inspect the existing transaction. A DUPLICATE_REFERENCE error is a recovery signal, not proof that the customer received airtime. Protect a local order from concurrent submissions with your application's transaction or job-locking mechanism.
Read the result correctly
An accepted purchase returns its identifier at data.id and its status at data.attributes.status. Persist the identifier with the order. Use the status method to retrieve it again, or reconcile a verified webhook. Pending must remain pending in your customer interface. Do not mark an order paid-and-delivered merely because the HTTP response was successful.
If a result is unrecognized, keep the order unresolved and investigate it. Never show exception details, keys or a raw provider response to the customer. A safe message explains that the result is being checked and provides a route to view the existing order or contact support.
Moving this into a live application
Add customer authorization, durable persistence, payment collection, webhook verification and ledger reconciliation in your own application. The client is the HTTP boundary, not a complete checkout or wallet. Your customer's payment to you and your partner purchase from RizPay are distinct records.
Review the production checklist before introducing a production client and live credentials. Keep test and live clients, keys and order records separate. The airtime reference describes the request; other product categories require their own fields and, for electricity and cable TV, verification.