> ## 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 payment use cases

> Four flows — a checkout, airtime top-up, handling a pending vend, and reconciling a day of bills.

## 1. A bill-payment checkout

The whole service in one flow, with the confirmation step where it belongs.

```ts theme={null}
// 1. The customer picks a biller and package from your cached catalog,
//    and types their meter or account number.
const enquiry = await post('/v1/bill-payments/enquiries', {
  customerId: form.meterNumber,
  billerId: form.billerId,
  packageId: form.packageId,
  amount: form.amount,          // "5000.00" — decimals, not minor units
});

// 2. Show what the biller says. This is the step that prevents paying a
//    stranger's meter, and it costs one screen.
const confirmed = await confirm({
  name: enquiry.customer.customerName,
  address: enquiry.customer.address,
  amount: enquiry.totalDue ?? form.amount,
  charge: enquiry.charge,
  youPay: enquiry.totalDebit,
});
if (!confirmed) return;

// 3. Enquiries last ten minutes. If the user hesitated, ask again.
const fresh = Date.parse(enquiry.expiresAt) - Date.now() > 60_000
  ? enquiry
  : await post('/v1/bill-payments/enquiries', { /* same body */ });

// 4. Pay.
const payment = await post('/v1/bill-payments', {
  enquiryId: fresh.id,
  accountId: ngnAccountId,
  amount: form.amount,
  externalReference: `bill_${order.id}_a${order.attempt}`,
  customerName: form.customerName,
  phoneNumber: form.phoneNumber,
});
```

The `totalDebit` in step 2 is what actually leaves the account. Showing `amount` instead is how a customer ends up surprised by the charge.

## 2. Airtime, in one fewer step

Airtime is the one product that does not need a package.

```ts theme={null}
const enquiry = await post('/v1/bill-payments/enquiries', {
  customerId: '+2348012345678',      // the phone number is the customer id
  billerId: airtimeBillerId,         // an airtime biller has no packages
  amount: '1000.00',
});

await post('/v1/bill-payments', {
  enquiryId: enquiry.id,
  accountId: ngnAccountId,
  amount: '1000.00',
  externalReference: `airtime_${request.id}`,
  customerName: 'Ada Okafor',
  phoneNumber: '+2348012345678',
});
```

Two things differ from a utility bill:

* **The customer id is the phone number**, normalised for you.
* **The recipient comes from the enquiry**, not from the `phoneNumber` in the payment body. Getting the number right matters at enquiry time.

Data bundles do need a package, and their price is fixed by it — send an amount that disagrees and you get `bill_amount_mismatch`.

## 3. A vend that has not resolved

`submitted` and `pending` are normal. The money has left; the biller has not finished.

```ts theme={null}
async function resolve(paymentId: string) {
  const payment = await get(`/v1/bill-payments/${paymentId}`);

  switch (payment.status) {
    case 'confirmed':
      await deliverToken(payment.tokenData);       // store it too
      return 'done';

    case 'failed':
      // Sakawave has already reversed the debit. Tell the customer —
      // a silent refund looks like a silent failure.
      await notifyReversed(payment.failureReason);
      return 'refunded';

    case 'pending':
    case 'submitted':
      // A reconciler is chasing it, up to five attempts. Do not resubmit.
      return 'waiting';
  }
}
```

<Warning>
  Never resubmit a pending payment. The account has already been debited, and a second payment under a new reference buys the value twice.
</Warning>

Better still, let the webhook drive it:

```ts theme={null}
onEvent('bills_payment.completed', (data) => deliverToken(data.relatedOperation.tokenData));
onEvent('bills_payment.failed', (data) => notifyReversed(data.relatedOperation.failureReason));
```

## 4. Reconcile a day of bills

```bash theme={null}
# Everything 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, biller: .biller.name, amount, vendStatus}'

# Everything that failed and was refunded.
curl --silent --url "$SAKAWAVE_BASE_URL/v1/bill-payments?status=failed&pageSize=100" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
| jq '.items[] | {id, totalDebit, failureReason}'
```

| Question | Where |
| - | - |
| What did we pay today? | `GET /v1/bill-payments`, page by `totalPages` |
| Is anything stuck? | `?status=pending` — and check how old |
| What was refunded? | `?status=failed`, then the reversal in transactions |
| What did the customer get? | The payment's `tokenData` |
| What did it cost us? | `charge` and `commission` per payment |

Remember the page shape here: `totalPages`, and no `hasNext`. Loop while `page < totalPages`.

## 5. Tell the two telco billers apart

A telco appears as a separate biller under Airtime and under Data, with
disjoint packages and different ids. Browse the group you mean, and keep the
biller `id` you got from it:

```ts theme={null}
const billers = await get(`/v1/bill-payments/groups/${dataGroupId}/billers`);
const mtn = billers.items.find((biller) => biller.name === 'MTN');
// mtn.id addresses MTN-under-Data only. The Airtime MTN has its own id.
```

Passing a biller id to a group it does not belong to `404`s, so a mismatched
pair fails loudly rather than quietly buying the wrong thing.

## What you cannot do here

* **Cancel or refund a payment.** A failure refunds itself; a success is final.
* **Pay in any currency but NGN.**
* **Pay a biller outside the catalog.**
* **Re-issue a token.** Store it when it arrives.
* **Extend an enquiry.** Make a new one.


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