The KwikBillng API
Raise invoices from your own software and move them through their lifecycle. They become your invoices — same numbering, same totals — and they appear on your KwikBillng dashboard beside the ones you type in by hand.
Contents
Introduction
A REST API over HTTPS. Every request carries an API key, every response is JSON, and every path below is relative to:
https://api.kwikbillng.com/api/v1
Three things are worth knowing before you write any code, because they shape everything else.
Writes are queued; reads answer immediately
A write answers 202 with a request_id and finishes on your webhook. Creating an invoice renders a PDF, builds a payment QR code and mails a customer, and none of that belongs in the time you are holding a connection open.
Access is by request, and it is free to ask
A person reads every request, and what you tell us about what you are building is most of what they go on. There is no plan requirement — any KwikBillng account can ask, from the developer section of the dashboard.
Calls are metered from a prepaid wallet
Not from the account’s plan. The two never touch: an account on the free plan with a funded wallet has a fully working API, and the same account raising an invoice through the dashboard spends plan quota and never the wallet. See Pricing.
Quickstart
Three steps, in order. Fund the wallet first — a rehearsal is metered like a live call, so an empty wallet refuses both.
1Check the key, and what is left to spend
curl https://api.kwikbillng.com/api/v1/x/account \ -H "Authorization: Bearer sk_test_7f3a2b91_…" curl https://api.kwikbillng.com/api/v1/x/wallet \ -H "Authorization: Bearer sk_test_7f3a2b91_…"
2Add a customer, then raise an invoice
curl -X POST https://api.kwikbillng.com/api/v1/x/customers \
-H "Authorization: Bearer sk_test_7f3a2b91_…" \
-H "Content-Type: application/json" \
-d '{"name":"Ada Obi","email":"ada@example.com"}'
curl -X POST https://api.kwikbillng.com/api/v1/x/invoices \
-H "Authorization: Bearer sk_test_7f3a2b91_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8891" \
-d '{
"customer_id": "c1e0a4f2-…",
"due_date": "2026-09-30",
"items": [{"description":"Design retainer","quantity":1,"unit_price":250000}]
}'Both answer 202. The customer’s id arrives on your webhook, or read it back from GET /x/customers.
3Mark it paid when the money lands
curl -X PATCH https://api.kwikbillng.com/api/v1/x/invoices/{id}/status \
-H "Authorization: Bearer sk_test_7f3a2b91_…" \
-H "Content-Type: application/json" \
-d '{"status":"paid","note":"Bank transfer, ref 8891"}'Authentication
A bearer token, on every request. Keys are minted in the developer section of the dashboard and shown once — only a digest is stored, so a lost key is replaced rather than recovered.
Authorization: Bearer sk_live_7f3a2b91_…
Test and live keys
A sk_test_… key writes records that stay out of the merchant’s real ones — no dashboard list, no export, no plan quota, no payout — and delivers to a separate test webhook URL. Test invoices are numbered TEST-INV-00001 in their own sequence, so a rehearsal never leaves a gap in the real one.
Build against a test key, but fund the wallet first. A rehearsal is metered at the same rates as a live call, and one the balance cannot cover is refused 402 exactly as a live one is.
A key is not a person
Records raised over the API carry no author. They appear on the merchant’s dashboard tagged API rather than attributed to a colleague, and on their audit log with no actor — which is how an owner tells what their software did from what their staff did.
How requests are answered
- 1
You POST
202 with a request_id, and what it costs
- 2
We do the work
Money is held, then taken only if it succeeds
- 3
We call your webhook
Signed, retried for ~8 hours if you are down
A 202 means the payload was good and the work is ours. Anything that goes wrong after that point is reported on your webhook, and the money held for the call is given back.
HTTP/1.1 202 Accepted
{
"status": "queued",
"request_id": "7e3776e5-1c4a-4b0e-9f3d-2a6b8c0d1e2f",
"event": "invoice.created",
"charge": {
"amount": 37.50,
"currency": "NGN",
"lines": [
{ "event": "invoice.created", "amount": 25.00 },
{ "event": "email.sent", "amount": 12.50 }
]
}
}Reads answer 200 with the payload under data:
{ "status": "ok", "data": { … } }Idempotency
Send Idempotency-Key on any write. Replaying the same key returns the first request’s answer instead of doing the work twice, and is not charged again.
Idempotency-Key: order-8891
Scoped per account and per mode, so rehearsing with a key does not burn the one you will use in live. Use your own reference — an order number, a job id — rather than a fresh value per attempt, or a retry after a timeout raises a second invoice.
Account
/x/accountFreeWhose key this is, and which mode it is pointed at.
The endpoint to call first, and again whenever something is confusing.
Request
curl https://api.kwikbillng.com/api/v1/x/account \ -H "Authorization: Bearer sk_test_7f3a2b91_…"
Response
{
"status": "ok",
"data": {
"merchant_id": "9f8e7d6c-…",
"name": "Pils Tech Limited",
"mode": "test",
"test_mode": true
}
}Wallet
/x/walletFreeWhat is left to spend.
Free, and deliberately: this is how you find out whether the next call will be refused, and charging for the answer would mean an account whose balance has run out cannot be told that it has.
Response fields
| balance | number | Everything funded, less everything spent. |
| reserved | number | Held against calls already accepted and not yet finished. |
| available | number | balance less reserved, and the only figure that decides whether the next call is accepted. Watch this one; the other two explain it. |
| currency | string | The wallet's own, and always the charge's. |
Request
curl https://api.kwikbillng.com/api/v1/x/wallet \ -H "Authorization: Bearer sk_live_7f3a2b91_…"
Response
{
"status": "ok",
"data": {
"mode": "live",
"currency": "NGN",
"balance": 49989.50,
"reserved": 37.50,
"available": 49952.00
}
}Scoped to your key’s own mode, like every other read here. A sk_test_ key reads the sandbox float; a sk_live_ key reads the money. The two balances never meet in either direction, so the figure you are watching is always the one your calls are spending.
Invoices
/x/invoices202 · webhookBillableRaise an invoice on the account.
Body
| customer_id | uuid · required | Must belong to this account. |
| due_date | date | YYYY-MM-DD. Omit for an invoice with no due date. |
| currency | string | Defaults to the account's own. |
| items | array · required | Each: description, quantity, unit_price. Totals are computed here — figures sent in the payload are ignored. |
| vat_rate | number | Overrides the account's rate, for this invoice only. |
| discount_type | string | percent or fixed, with discount_value. Comes off the subtotal before VAT. |
Request
curl -X POST https://api.kwikbillng.com/api/v1/x/invoices \
-H "Authorization: Bearer sk_live_7f3a2b91_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8891" \
-d '{
"customer_id": "c1e0a4f2-…",
"due_date": "2026-09-30",
"items": [
{ "description": "Design retainer", "quantity": 1, "unit_price": 250000 }
]
}'Response
HTTP/1.1 202 Accepted
{
"status": "queued",
"request_id": "7e3776e5-…",
"event": "invoice.created",
"charge": { "amount": 37.50, "currency": "NGN", "lines": [ … ] }
}The invoice, its number and its PDF arrive on the invoice.created webhook.
/x/invoices/{id}/status202 · webhookBillable if it notifiesMove an invoice along its lifecycle.
Body
| status | string · required | sent · viewed · partially_paid · paid · overdue · cancelled · void |
| note | string | Why, in your own words. Kept on the invoice's status history and the account's audit log. |
| amount | number | Required for partially_paid and refused everywhere else. An amount that would settle the invoice is refused, so repeating a part payment can never quietly close it. |
Request
curl -X PATCH https://api.kwikbillng.com/api/v1/x/invoices/b41646b7-…/status \
-H "Authorization: Bearer sk_live_7f3a2b91_…" \
-H "Content-Type: application/json" \
-d '{ "status": "paid", "note": "Bank transfer, ref 8891" }'Response
HTTP/1.1 202 Accepted
{
"status": "queued",
"request_id": "dfcffefe-…",
"event": "invoice.status_changed",
"charge": {
"amount": 12.50,
"currency": "NGN",
"lines": [ { "event": "email.sent", "amount": 12.50 } ]
}
}Allowed moves
| From | To | |
|---|---|---|
| draft | sent · paid · cancelled | Straight to paid, because cash exists. |
| sent | viewed · partially_paid · paid · overdue · cancelled | |
| viewed | partially_paid · paid · overdue · cancelled | |
| partially_paid | partially_paid · paid · overdue | Leads back to itself: instalments are real, and each carries its own amount. |
| overdue | partially_paid · paid · void | |
| paid · cancelled · void | — | Final. Nothing moves out of these. |
What it costs
The move itself is free. What can cost is the notification a settled invoice sends: moving to paid mails the customer to say their invoice is closed, billed as email.sent if the account has notifications on. Every other move writes to nobody and bills nothing, and charge.lines says which happened.
Paid and partially paid also record the money
That is a customer paying in cash or by transfer, and no processor webhook will ever arrive to write it — so the payment is recorded here, and the invoice’s balance moves with its status.
An illegal move is refused on the webhook, not here
Asking for a move the table does not allow still answers 202. It is attempted once, refused, and reported as invoice.status_failed within seconds — never retried, since the table will not have changed. The rule is enforced once, where the row is locked, rather than twice with a gap in between for the invoice to move.
/x/invoicesFreeList invoices, newest first.
Query parameters
| status | string | Filter to one status. |
| customer_id | uuid | Filter to one customer. |
| page, page_size | integer | Defaults to page 1, 20 a page. |
Scoped to the key’s own mode: a test key sees the sandbox and only the sandbox. Pagination rides in the X-Total-Count, X-Page and X-Page-Size headers.
/x/invoices/{id}FreeFetch one invoice, with its line items.
/x/invoices/{id}/paymentsFreeWhat has been paid against it, and how.
Customers
/x/customers202 · webhookFreeAdd a customer.
Body
| name | string · required | The only required field. |
| string | Where invoices and receipts go. Without one, nothing is mailed and nothing is charged for mailing. | |
| phone | string | |
| address | string | Printed on the invoice. |
Request
curl -X POST https://api.kwikbillng.com/api/v1/x/customers \
-H "Authorization: Bearer sk_live_7f3a2b91_…" \
-H "Content-Type: application/json" \
-d '{ "name": "Ada Obi", "email": "ada@example.com" }'Response
HTTP/1.1 202 Accepted
{
"status": "queued",
"request_id": "a3f1b2c4-…",
"event": "customer.created"
}Free, but queued like every other write so you meet one model rather than two. No charge comes back, because nothing was charged.
/x/customersFreeList customers.
Query parameters
| q | string | Search by name or email. |
| page, page_size | integer | Defaults to page 1, 20 a page. |
/x/customers/{id}FreeFetch one customer.
Receipts
/x/receipts/{reference}BillableIssue a receipt for a payment.
Keyed by the payment reference rather than the invoice: a receipt is for money that arrived, and an invoice paid in instalments has one per payment.
Charged synchronously — there is no queue here, because there is no gap between accepting the request and doing the work. If the receipt cannot be produced, the charge is given back.
Webhooks
Register one URL per mode in the developer section of the dashboard. Every queued request finishes with a signed POST to it.
| Event | Sent when | data carries |
|---|---|---|
| invoice.created | POST /x/invoices succeeded | The invoice, with its number |
| invoice.failed | POST /x/invoices could not be done | Why. Nothing was charged. |
| invoice.status_changed | PATCH /x/invoices/{id}/status succeeded | The invoice as it now stands |
| invoice.status_failed | The move was refused | Usually a transition the lifecycle does not allow |
| customer.created | POST /x/customers succeeded | The customer, with its id |
| customer.failed | POST /x/customers could not be done | Why |
POST /your-endpoint
X-KwikBillng-Signature: t=1735689600,v1=5f2c…
X-KwikBillng-Event: invoice.created
X-KwikBillng-Delivery: 8966f928-a630-4973-99ea-6961de2e0bc2
X-KwikBillng-Attempt: 1
{
"event": "invoice.created",
"id": "c1e0a4f2-…",
"request_id": "7e3776e5-…",
"test_mode": false,
"created_at": "2026-08-27T13:00:00Z",
"data": { "id": "…", "invoice_number": "INV-00042", "total": 32250 }
}Verifying the signature
HMAC-SHA256 over "{timestamp}.{raw body}", keyed with your signing secret. Use the raw bytes — re-serialising the JSON changes the key order and the signature will not match.
const [, t] = header.match(/t=(\d+)/);
const [, v1] = header.match(/v1=([a-f0-9]+)/);
const expected = crypto
.createHmac("sha256", process.env.KWIKBILLNG_WEBHOOK_SECRET)
.update(t + "." + rawBody)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));Reject anything more than a few minutes old. The timestamp is inside the signed string, which is what stops a captured delivery being replayed at you later.
Answer 2xx, and answer quickly
Any 2xx counts as accepted — including 202 if you queue the event and handle it later, which is what we would do. We wait 20 seconds and no longer.
Redirects are not followed. A 3xx is recorded and retried, because delivering a signed payload to a host you did not register is how a misconfigured endpoint becomes a leak.
When it fails
We retry for about eight hours — ten attempts, backing off from 30 seconds to a four-hour cap. Every attempt is kept, with the status your server returned and an excerpt of its body, and the merchant can read all of it in the portal and press resend once you have deployed a fix.
Errors
One envelope, everywhere:
{
"error": "insufficient funds",
"details": "this call costs 37.50 NGN and the developer wallet has 10.00 available; fund it with at least 27.50 more"
}error is stable and safe to branch on. details is for a person reading a log, and may be absent.
| 400 | The payload is wrong. details names the field. |
| 401 | The key is missing, malformed, unknown, or revoked. All four answer alike on purpose. |
| 402 | The wallet cannot cover this call. details names the shortfall. |
| 403 | The account is not approved for API access, or has been suspended. |
| 404 | No such record — or it is in the other mode, which is the same answer. |
| 409 | A conflict, such as resending an event that already arrived. |
| 429 | Too fast. Retry-After says how long to wait. |
| 503 | The API is switched off, or a dependency is down. Safe to retry. |
Pricing
You are charged for work, not for requests. Reading anything is free, and so is adding a customer.
| Event | What triggers it | Price |
|---|---|---|
Invoice created invoice.created | Charged once per invoice the API creates. Reads of an invoice are free, and so is creating the customer it belongs to. | ₦1.50 |
Receipt issued receipt.issued | Charged when a receipt is issued for a settled payment. | ₦2.00 |
Email notification email.sent | Charged per notification actually sent. Turn email notifications off in the portal and this stops applying. | ₦3.00 |
Minimum top-up
Wallet top-ups start at ₦50,000. That is per payment, not a balance you have to keep — you can spend it all the way down, and calls are only refused when the balance cannot cover the next one.
Nothing is charged for our failures
The cost is held when a call is accepted, taken only when the work succeeds, and given back when it does not. A failed call costs nothing, and GET /x/wallet shows what is held while one is in flight.
A rehearsal is metered the same, and paid for differently
A sk_test_… key is metered exactly as a live one is: the same tariff, the same reservation, the same 402 when the balance will not stretch. That is deliberate, and it is what makes the sandbox worth rehearsing in — what you learn there about what a flow costs stays true when you swap the key.
What it spends is a separate sandbox wallet, credited free and instantly from the merchant’s portal. Rehearsing does not cost the merchant money and cannot drain the balance their live integration depends on. Sandbox charges are marked (test) on their own statement.
Rate limits
Per key. Exceeding one answers 429 with Retry-After in seconds — wait that long rather than retrying immediately, which is the difference between a queue draining and a queue growing.
If your integration needs a higher ceiling than the one you have, ask. It is a number in a configuration file, not a commercial negotiation.
Ready to start?
Ask for access from the developer section of your dashboard. Tell us what you are building — a sentence or two genuinely helps.