> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sakawave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bill payments setup and configuration

> Account requirements, the ten-minute window, idempotency, and how pending payments resolve themselves.

## Credentials and accounts

API key only. The paying account must be:

* **active**, and **debitable**
* **NGN** — no other currency is accepted
* **one of your own** — a customer wallet is refused

Anything else is `409 bill_payment_account_ineligible`, one code for all four causes.

There is no tier gate and no feature flag on bill payments. If your key works and you hold an eligible NGN account, the service is available.

## The catalog

Synchronised from the provider and cached by Sakawave. If it is empty when you call, Sakawave refreshes it inline; if that refresh fails, you get `503 catalog_unavailable`.

Cache it on your side too — an hour is reasonable — but re-read before an enquiry if your cache is old. A biller withdrawn from the catalog between your enquiry and your payment produces `409 bill_product_unavailable`.

<Note>
  Catalog identifiers are Sakawave ULIDs and are stable across renames and across a change of upstream provider. Store the `id` against your own product records.
</Note>

## The ten-minute window

An enquiry expires ten minutes after it is made, and is spent by the payment that uses it.

```ts theme={null}
// Wrong: the enquiry ages while the user reads the screen.
const enquiry = await enquire(meterNumber);
await showConfirmationScreen(enquiry);        // user goes to make tea
await pay(enquiry.id);                        // 409 bill_enquiry_expired

// Right: enquire, confirm, pay — and re-enquire if the user was slow.
let enquiry = await enquire(meterNumber);
const confirmed = await showConfirmationScreen(enquiry);
if (Date.parse(enquiry.expiresAt) - Date.now() < 60_000) {
  enquiry = await enquire(meterNumber);       // fresh price, fresh name
}
await pay(enquiry.id);
```

Re-enquiring is cheap and is the correct response to any expiry. Do not try to extend one.

## Idempotency

`externalReference` is the key, unique within your business.

| Situation | Result |
| - | - |
| Reused after success | Returns the existing payment |
| Reused after failure | Returns the **failure** |
| Reused with a different enquiry | Returns the **old** payment |

This is the transfer contract, not the card one: a failed bill payment does **not** re-drive under the same reference. A genuine retry needs a new reference *and* a new enquiry — the old one is spent.

```ts theme={null}
`bill_${orderId}_a${attempt}`   // distinguishes a retry from a repeat
```

## Rate limits

| Operation | Requests per minute |
| - | -: |
| Catalog reads, payment reads | 600 |
| `POST /v1/bill-payments/enquiries` | 120 |
| `POST /v1/bill-payments` | 120 |

Every enquiry is a live call to the biller, so the enquiry limit is the one a batch will meet first. Two calls per bill means 60 bills a minute at best.

## Pending payments resolve themselves

A payment that the biller has not answered goes `pending`, and a background reconciler chases it — up to **five** attempts before giving up and marking it `failed`.

That means:

* **A pending payment is being worked on.** Do not resubmit it.
* **Do not poll it aggressively.** Once a minute is plenty; the webhook is better.
* **A failure after retries reverses the debit** like any other failure, with the retry count noted in `failureReason`.

```bash theme={null}
# Anything still unresolved.
curl --silent --url "$SAKAWAVE_BASE_URL/v1/bill-payments?status=pending&pageSize=100" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" | jq '.items[] | {id, createdAt, vendStatus}'
```

## Webhooks

| Event | Meaning |
| - | - |
| `bills_payment.created` | Submitted to the biller; your account is already debited |
| `bills_payment.completed` | Confirmed — value delivered |
| `bills_payment.failed` | Failed; the debit has been reversed |

The payload is the ledger transaction, with the bill in `relatedOperation`: biller and package names, customer name and address, the last four of the reference, `vendStatus`, and `tokenData` where there is one.

That makes the webhook a viable token-delivery trigger — but store the token from the payment resource, which is the authority.

## Failures refund themselves

When a bill payment fails, Sakawave posts a reversal against the same account automatically. You do not raise a refund, and you should not build a claims process around it.

What you should build is a **notification**: a customer who paid for electricity and got a reversal instead needs telling, and the reversal alone will not tell them.

## Configuration checklist

* [ ] An eligible NGN account identified before you start
* [ ] Decimal amounts used throughout — this service does not use minor units
* [ ] The enquiry made immediately before the payment, and re-made when close to expiry
* [ ] `customer.customerName` shown to the payer before taking money
* [ ] `totalDebit` shown, not `amount`
* [ ] `externalReference` namespaced per attempt, since retries need a new one
* [ ] `confirmed` treated as success — not `completed`
* [ ] `tokenData` stored on receipt; it is not re-issued
* [ ] A customer-facing notification wired to `bills_payment.failed`


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.