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. 1

    You POST

    202 with a request_id, and what it costs

  2. 2

    We do the work

    Money is held, then taken only if it succeeds

  3. 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

GET/x/accountFree

Whose 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

GET/x/walletFree

What 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

balancenumberEverything funded, less everything spent.
reservednumberHeld against calls already accepted and not yet finished.
availablenumberbalance less reserved, and the only figure that decides whether the next call is accepted. Watch this one; the other two explain it.
currencystringThe 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

POST/x/invoices202 · webhookBillable

Raise an invoice on the account.

Body

customer_iduuid · requiredMust belong to this account.
due_datedateYYYY-MM-DD. Omit for an invoice with no due date.
currencystringDefaults to the account's own.
itemsarray · requiredEach: description, quantity, unit_price. Totals are computed here — figures sent in the payload are ignored.
vat_ratenumberOverrides the account's rate, for this invoice only.
discount_typestringpercent 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.

PATCH/x/invoices/{id}/status202 · webhookBillable if it notifies

Move an invoice along its lifecycle.

Body

statusstring · requiredsent · viewed · partially_paid · paid · overdue · cancelled · void
notestringWhy, in your own words. Kept on the invoice's status history and the account's audit log.
amountnumberRequired 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

FromTo
draftsent · paid · cancelledStraight to paid, because cash exists.
sentviewed · partially_paid · paid · overdue · cancelled
viewedpartially_paid · paid · overdue · cancelled
partially_paidpartially_paid · paid · overdueLeads back to itself: instalments are real, and each carries its own amount.
overduepartially_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.

GET/x/invoicesFree

List invoices, newest first.

Query parameters

statusstringFilter to one status.
customer_iduuidFilter to one customer.
page, page_sizeintegerDefaults 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.

GET/x/invoices/{id}Free

Fetch one invoice, with its line items.

GET/x/invoices/{id}/paymentsFree

What has been paid against it, and how.

Customers

POST/x/customers202 · webhookFree

Add a customer.

Body

namestring · requiredThe only required field.
emailstringWhere invoices and receipts go. Without one, nothing is mailed and nothing is charged for mailing.
phonestring
addressstringPrinted 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.

GET/x/customersFree

List customers.

Query parameters

qstringSearch by name or email.
page, page_sizeintegerDefaults to page 1, 20 a page.
GET/x/customers/{id}Free

Fetch one customer.

Receipts

GET/x/receipts/{reference}Billable

Issue 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.

EventSent whendata carries
invoice.createdPOST /x/invoices succeededThe invoice, with its number
invoice.failedPOST /x/invoices could not be doneWhy. Nothing was charged.
invoice.status_changedPATCH /x/invoices/{id}/status succeededThe invoice as it now stands
invoice.status_failedThe move was refusedUsually a transition the lifecycle does not allow
customer.createdPOST /x/customers succeededThe customer, with its id
customer.failedPOST /x/customers could not be doneWhy
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.

400The payload is wrong. details names the field.
401The key is missing, malformed, unknown, or revoked. All four answer alike on purpose.
402The wallet cannot cover this call. details names the shortfall.
403The account is not approved for API access, or has been suspended.
404No such record — or it is in the other mode, which is the same answer.
409A conflict, such as resending an event that already arrived.
429Too fast. Retry-After says how long to wait.
503The 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.

EventWhat triggers itPrice

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.