Bills & VTU
Buy airtime, data, electricity and cable TV for your customers, charged to your prepaid balance. Read the catalogue, buy with an idempotency key, store what was delivered, and follow each purchase to the end.
Before you start
- A key with the
purchasescope, and each product you sell enabled on your account —GET /v1/partner/melists them inallowed_products. A product that is not enabled answers403. Reading the catalogue needs onlyread. - A cleared balance that covers the purchase and our fee. Otherwise the purchase is refused with
402 insufficient_fundsor409 funds_not_clearedbefore anything is bought. - Read Idempotency and Indeterminate transactions first, and send a
your_referencewith every purchase. Step 3 explains why it matters more here than anywhere else.
The products
| Product | Endpoint | customer_reference | Catalogue codes |
|---|---|---|---|
airtime | POST /v1/partner/purchases/airtime | the phone number | none |
data | POST /v1/partner/purchases/data | the phone number | biller_code and package_code |
electricity | POST /v1/partner/purchases/electricity | the meter number | biller_code and package_code |
cable | POST /v1/partner/purchases/cable | the smartcard or decoder number | biller_code and package_code |
exam_pin | POST /v1/partner/purchases/exam_pin | see below | biller_code |
Exam PINs cannot be bought correctly yet — see what is not there yet.
1. Read the catalogue
The catalogue covers cable, data and electricity. First, who sells the product:
curl $VERTEXBRIDGE_API_BASE/v1/partner/catalogue/electricity \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY"Then what that biller sells:
curl $VERTEXBRIDGE_API_BASE/v1/partner/catalogue/electricity/$BILLER_CODE \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY"- A code is opaque. Take
biller_codeandpackage_codefrom these lists and send them back unchanged. Never parse one, and never hardcode one you saw once: what a code refers to can change on our side, and a stored code then fails without any warning you could have seen. - An empty list is an answer. It means nothing is available for that product right now — not that the product does not exist, and not that something broke.
- Airtime has no catalogue, by design: it is bought with a phone number and an amount. Asking for its catalogue answers
422, naming the products that have one. - A package's
amountcan benull— a prepaid electricity meter has no fixed price. Then you choose the amount. Never show a null as ₦0.00.
2. Buy
curl -X POST $VERTEXBRIDGE_API_BASE/v1/partner/purchases/airtime \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY" \
-H "Idempotency-Key: 3c1e9b7a-2d4f-4a6b-9e8c-7f1a2b3c4d5e" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 10000,
"customer_reference": "08000000000",
"your_reference": "order-91c2a7"
}'| Field | Required | Meaning |
|---|---|---|
amount_minor | Yes | In kobo, as a JSON integer greater than zero — 10000 is ₦100.00. For a package with a fixed amount, send that amount: we do not check that the two match, so a mismatch is yours to prevent. |
customer_reference | Yes | Who the purchase is for — see the products table. 3 to 64 characters. |
biller_code, package_code | Per product | From the catalogue, unchanged. |
your_reference | No — but send it | Your own id for the purchase, up to 128 characters. |
Every purchase needs an Idempotency-Key header. The request body also accepts an optional subject_id; there is no endpoint yet that gives you a value for it, so leave it out.
3. Read the answer
| Status | Means | Do |
|---|---|---|
| 201 | We bought it, and state says how the biller answered. | Store the reference, the state and data. |
| 202 | The biller was told and did not answer. The state is pending. | Do not retry and do not refund your customer. Follow it (step 5). |
| 200 | A replay of a key you already used: the original answer. Nothing was bought again. | Treat it as the original. |
| 402, 409, 422, 429 | This request bought nothing. A 409 request_in_progress means your first request with this key is still running — send the identical request again in a moment. | Fix the cause; see Errors. |
| 503 | Either nothing was bought, or it reached the biller and we could not confirm it. | Do not retry. Look it up by your_reference (step 5). |
A 503 on a purchase does not say whether the biller was reached, and it carries no reference of ours. If you sent a your_reference, GET /v1/partner/transactions?your_reference=… answers it: no transaction means nothing was bought; otherwise follow its state. Without one, all you can do is contact us with the correlation_id.
4. Store what was delivered
data is what your customer bought — a meter token, a PIN reference. It is on the purchase response when the purchase succeeds straight away, and on GET /v1/partner/purchases/{reference} once it has succeeded — including when that happens later than our first answer to you. It is not in the transactions list.
{}means nothing has been delivered yet. It is the same on the purchase response and on the read, so there is one “not yet” to handle, not two.- A replay with the same key returns the original answer — so if that was
{}, the replay is too. To collect a delivery that arrived later, read the purchase; do not replay it. - Store the delivery when you first see it: it is what your customer paid for.
5. Follow it to the end
GET /v1/partner/purchases/{reference} returns the state as it is now. Read it until it is succeeded or failed; while it is pending or indeterminate, do not refund your customer and do not buy again under a new key. A failed purchase gives its money back to your balance. Lost our answer entirely? Find it by the your_reference you sent, with GET /v1/partner/transactions?your_reference=….
What is not there yet
- Exam PINs.
POST /v1/partner/purchases/exam_pinneeds abiller_codefrom the catalogue, but the catalogue does not cover exam PINs, so there is nowhere to get one. Leave exam PINs aside until it does. - Checking a customer before you buy. A package can say
requires_validation— a meter number belongs to a named person, and buying against the wrong one sends money somewhere it cannot be recovered from. There is no call to check one yet, and the purchase does not check it for you. Confirm the number with your customer before you buy.
End to end
import { randomUUID } from 'node:crypto'
const API = process.env.VERTEXBRIDGE_API_BASE + '/v1/partner'
const auth = { 'X-API-Key': process.env.VERTEXBRIDGE_API_KEY }
// 1. Who sells it, and what do they sell? Keep every code exactly as given.
const billers = await (await fetch(`${API}/catalogue/electricity`, { headers: auth })).json()
const biller = billers.find((b) => b.code === chosenBillerCode)
const packages = await (await fetch(`${API}/catalogue/electricity/${biller.code}`, { headers: auth })).json()
const pkg = packages.find((p) => p.code === chosenPackageCode)
// 2. Buy — with a key and your own reference, both SAVED before the request.
const order = await db.orders.create({ idempotencyKey: randomUUID(), state: 'buying' })
const res = await fetch(`${API}/purchases/electricity`, {
method: 'POST',
headers: { ...auth, 'Idempotency-Key': order.idempotencyKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
amount_minor, // what your customer is paying for, in kobo
customer_reference: meterNumber,
biller_code: biller.code,
package_code: pkg.code,
your_reference: order.id,
}),
})
const body = await res.json()
// 3–4. Store the delivery when you first see it. If it is {} (not succeeded yet),
// read GET /purchases/{reference} until it has succeeded — the delivery appears there.
if (res.ok) await db.orders.update(order.id, { reference: body.reference, state: body.state, delivered: body.data })
// 5. Not final? Read it later with GET /purchases/{reference} — never buy it again.