This guide puts the other guides together into one integration you can run. Your server creates a customer and an invoice, hands the customer a hosted checkout link, hears about the payment through a signed webhook, and checks its own records against Roamerwave's. It uses Node.js 20 or later and no libraries beyond Node itself.
Every request goes to https://api.roamerwave.com with Authorization: Bearer rw_test_…. Keys beginning rw_test_ only work with your test workspace, and the payment methods there are test payment methods that Roamerwave sets up for you. Create a key with these scopes in the Developers section of the workspace: customers:write, invoices:write, invoices:read and payments:read.
How the pieces fit
- Your server creates a customer and an invoice, then a checkout session for the invoice.
- You send the customer the
checkoutUrl. They pay on the Roamerwave-hosted page. - Roamerwave sends a signed
payment.succeededwebhook to your endpoint. It carries identifiers only. - Your server reads the payment and the invoice from the API, and fulfils only if the invoice says
succeeded. - On a schedule, your server lists invoices and matches them against its own orders, which catches anything a webhook did not.
Keep three rules in mind throughout. Never treat a timeout as a failed payment. Never fulfil on anything except a confirmed result read from the API. Send an Idempotency-Key on every write.
1. A small API helper
const API = "https://api.roamerwave.com";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function roamerwave(method, path, { body, idempotencyKey } = {}) {
const headers = {
Authorization: `Bearer ${process.env.ROAMERWAVE_API_KEY}`,
Accept: "application/json",
};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
for (let attempt = 0; ; attempt += 1) {
let response;
try {
response = await fetch(`${API}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(15_000),
});
} catch (networkError) {
// A timeout says nothing about whether the request arrived. The same request is safe to
// repeat: writes carry an Idempotency-Key and reads change nothing.
if (attempt >= 3) throw networkError;
await sleep(500 * 2 ** attempt);
continue;
}
if ((response.status === 429 || response.status >= 500) && attempt < 3) {
await sleep(500 * 2 ** attempt); // backoff: 0.5 s, 1 s, 2 s
continue;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
// { error: { code, message, requestId, details } }. Quote requestId to support.
throw Object.assign(new Error(payload?.error?.message ?? `HTTP ${response.status}`), {
status: response.status,
code: payload?.error?.code,
});
}
return payload;
}
}
It retries a rate limit (429), a 5xx answer or a timeout up to three times with a growing delay. That is safe because every write carries an Idempotency-Key. A timeout or a 5xx is never a payment result. Use it for everything below. Store the secret in an environment variable and keep it out of source control and out of the browser.
2. Create a customer and an invoice
async function createOrderInvoice(order) {
const customer = await roamerwave("POST", "/v1/customers", {
idempotencyKey: `customer-${order.customerRef}`,
// Your own record that the customer agreed to be billed through Roamerwave,
// for example a ticked box at checkout. Never send a constant.
body: {
reference: order.customerRef,
name: order.name,
phone: order.phone,
smsConsentAttested: order.customerAgreed === true,
},
});
const invoice = await roamerwave("POST", "/v1/invoices", {
idempotencyKey: `invoice-${order.id}`,
body: {
customerId: customer.id,
amount: { minor: order.amountMinor, currency: "KES" }, // "135000" is KES 1,350.00
description: order.description,
dueAt: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
reference: order.id,
},
});
return invoice; // store invoice.id against your order
}
phone is a Kenyan mobile number in international format, such as +254712345318. Store invoice.id with your order straight away. The invoice you get back does not repeat the reference you sent, so the id is how you find the order again. (A search with q=<your reference> on the invoice list also matches it.)
3. Open the hosted checkout
const session = await roamerwave("POST", `/v1/invoices/${invoice.id}/checkout-sessions`, {
idempotencyKey: `checkout-${order.id}`,
body: {},
});
// session.checkoutUrl -> send this to your customer
checkoutUrl appears in this response only. Give it to your customer in your page or message, and do not log it. If your request times out, send it again with the same Idempotency-Key: you get the original link back, not a second one. The customer chooses a wallet and approves the payment on their phone. Your server never touches a PIN or a card. A redirect back to your site only means the customer left the payment page. It never proves payment.
4. Receive and verify the webhook
Add an endpoint in the Developers section of the workspace. It must be an https URL on your server. The signing secret, beginning whsec_, is shown once.
import crypto from "node:crypto";
import http from "node:http";
const SECRET = process.env.ROAMERWAVE_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Set ROAMERWAVE_WEBHOOK_SECRET to the endpoint's whsec_ secret");
const MAX_BODY_BYTES = 64 * 1024;
const seen = new Set(); // use a unique column in your database instead
function verify(rawBody, header, secret, now = Date.now() / 1000) {
let t = null;
const signatures = [];
for (const item of String(header).split(",")) {
const separator = item.indexOf("=");
if (separator < 0) return false;
const name = item.slice(0, separator).trim();
const value = item.slice(separator + 1).trim();
if (name === "t") {
if (t !== null || !/^\d+$/.test(value)) return false;
t = Number(value);
} else if (name === "v1") {
signatures.push(value);
}
}
if (t === null || signatures.length === 0 || Math.abs(now - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return signatures.some(
(value) => /^[0-9a-f]{64}$/.test(value) && crypto.timingSafeEqual(Buffer.from(value, "hex"), expected),
);
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw Object.assign(new Error("too large"), { status: 413 });
chunks.push(chunk);
}
return Buffer.concat(chunks); // the exact bytes, before any JSON parsing
}
const server = http.createServer(async (req, res) => {
try {
const raw = await readBody(req); // throws if the client hangs up mid-body
if (!verify(raw, req.headers["roamerwave-signature"], SECRET)) {
return res.writeHead(400).end();
}
const event = JSON.parse(raw.toString("utf8")); // verified bytes, but still guard the parse
const eventId = req.headers["roamerwave-event-id"];
if (seen.has(eventId)) return res.writeHead(200).end(); // a repeat: already handled
seen.add(eventId);
res.writeHead(200).end(); // answer first, work afterwards
handleEvent(event).catch(console.error);
} catch (error) {
if (res.headersSent || res.destroyed) return;
const status = error.status ?? (error instanceof SyntaxError ? 400 : 500);
res.writeHead(status, { Connection: "close" }).end();
}
});
server.listen(3100, "127.0.0.1");
The handler answers 400 to a bad signature or a body that is not JSON, 413 to a body over 64 KB, and survives a client that disconnects part way. Read the raw bytes before parsing: re-serialising the JSON changes them and breaks the signature. If you use a framework, switch its JSON body parser off for this webhook endpoint, or use its raw-body option. The full rules, including secret rotation, are in Verify webhooks.
To check your own verifier, use this published test vector. It signs nothing real. Pass 1792574043 as the current time, because a real verifier rejects a delivery that old.
secret: whsec_test_vector_secret_do_not_use
header: t=1792574043,kid=wsk_old,v1=300bdf55caadc55844cb78666efa3254da841140ac4f82a6ce7e5a5465943696
The body is one line of JSON, and the bytes matter exactly:
{"id":"evt_0123456789abcdef","type":"payment.succeeded","schemaVersion":"1","workspaceId":"ws_0123456789abcdef","resourceId":"payatt_0123456789abcdef","occurredAt":"2026-10-21T09:14:03+00:00","data":{"object":{"id":"payatt_0123456789abcdef","type":"payment_attempt"}}}
Treat the event as a pointer
Events carry identifiers only, so read the current state from the API. The resourceId of a payment.succeeded event is a payment attempt id.
async function handleEvent(event) {
if (event.type !== "payment.succeeded") return; // invoice.created, webhook.test
const attempt = await roamerwave("GET", `/v1/payment-attempts/${event.resourceId}`);
const invoice = await roamerwave("GET", `/v1/invoices/${attempt.invoiceId}`);
if (invoice.paymentStatus === "succeeded") {
await markOrderPaid(invoice.id); // look the order up by the invoice id you stored
}
}
Roamerwave sends three event types: invoice.created, payment.succeeded and webhook.test. There is no webhook for a failed or an unknown payment, so learn those by reading the invoice, as the next section shows. If your handler fails after you answered 2xx, Roamerwave does not retry it, so keep step 6 as your safety net.
5. When the payment result is unknown
Sometimes the provider does not answer in time, or answers ambiguously, and Roamerwave cannot say whether the customer was charged. The invoice then has paymentStatus: "unknown". This is a real answer, not an error.
- Do not call it a failure. The customer may have paid. A timeout, on your side or ours, never becomes a failure.
- Do not fulfil. It is not a success either.
- Do not ask the customer to pay again, and do not create another checkout link. While the result is unknown Roamerwave does not offer a new payment link or send a reminder for that invoice (
eligibility.sendPaymentLink.reasonispayment_state_unknown). - Wait. The result stays unknown until the provider's own records resolve it, and then it becomes
succeededorfailedwith that evidence behind it.
async function checkOrder(order) {
const invoice = await roamerwave("GET", `/v1/invoices/${order.invoiceId}`);
switch (invoice.paymentStatus) {
case "succeeded":
return "fulfil";
case "unknown":
case "pending":
return "wait"; // show "we are confirming your payment"; never re-prompt
case "failed":
return "failed"; // see below: a retry is a new order
default:
return "awaiting_payment"; // "due"
}
}
A retry after a failed payment needs a new order id and a new Idempotency-Key, because the same key replays the same invoice and link.
While you wait, poll gently, for example every few minutes, and show your customer an honest "we are confirming your payment" message. The payment.succeeded webhook also arrives the moment a payment is confirmed. To see every invoice in this state, list invoices with the filter status=unknown.
6. Reconcile
A webhook can be missed, and your own handler can fail. Reconcile by listing invoices and matching them to your orders. Do not scan every invoice you have ever had: limit each run to a window of due dates that covers your open orders (here the last 30 days, which covers invoices due seven days after creation), and run it on a schedule such as hourly. This needs invoices:read.
async function reconcile(ordersByInvoiceId, windowDays = 30) {
const day = 24 * 3600 * 1000;
const dueFrom = new Date(Date.now() - windowDays * day).toISOString();
const dueTo = new Date(Date.now() + windowDays * day).toISOString();
const report = { paid: [], waiting: [], unmatched: [] };
for (const [status, bucket] of [["succeeded", "paid"], ["unknown", "waiting"]]) {
let cursor = null;
do {
const query = new URLSearchParams({ status, dueFrom, dueTo, limit: "100", ...(cursor ? { cursor } : {}) });
const page = await roamerwave("GET", `/v1/invoices?${query}`);
for (const invoice of page.data) {
const order = ordersByInvoiceId.get(invoice.id);
if (order) report[bucket].push(order.id);
else report.unmatched.push(invoice.id); // an invoice you have no order for
}
cursor = page.hasMore ? page.nextCursor : null;
} while (cursor);
}
return report;
}
Each page returns data, hasMore and nextCursor. A cursor only works with the exact filters that produced it. dueFrom and dueTo narrow the list by due date; both need a time zone. The list has no filter for the date a payment was made, so the window is by due date. An invoice outside the window is not checked, so pick a window that covers your open orders, or read each open order's invoice by id instead. Reading is cheap and bounded by your own open orders.
Read the three facts on each invoice separately:
| Field | Tells you |
|---|---|
paymentStatus | Whether the provider confirmed the customer's payment. |
reconciliationStatus | not_applicable, unmatched, matched or exception: whether the confirmed payment has been matched to this invoice. |
settlementStatus | not_reported, pending, settled, held or reversed: whether the money has been reported as settled to you. |
Fulfil on paymentStatus alone. Treat reconciliationStatus and settlementStatus as separate records for your accounts: a payment can succeed before it is reconciled, and be reconciled before it is settled. For a paid invoice, paidAttemptId names the payment attempt that paid it, and GET /v1/payment-attempts/{attemptId} returns that attempt (payments:read). Keep the invoice id and attempt id with your order as the shared reference.
Idempotency keys
Every POST needs an Idempotency-Key of 8 to 255 characters. Build it from your own record, so a retry repeats the same key.
| Request | Example key |
|---|---|
| Create customer | customer-<your customer reference> |
| Create invoice | invoice-<your order id> |
| Create checkout session | checkout-<your order id> |
The same key with the same body returns the original result and creates nothing new. The same key with a different body is refused with 409 IDEMPOTENCY_KEY_REUSED, so a changed amount needs a new order id, not the old key. Reads need no key.
Errors and limits
Errors share one shape, with error.code, error.message, error.requestId and error.details. A 429 RATE_LIMITED means slow down and retry. Anything you cannot interpret, such as a timeout or a 5xx, is not a payment result: retry the same request with the same Idempotency-Key, or read the invoice.
Next
- The API reference lists every endpoint and field used here.
- Take a payment explains attempt statuses and
nextActionin more depth. - Subscribe a customer creates invoices for you each period.