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

# Get started with bill payments

> Browse the catalog, validate a customer, pay, and read the token — four calls.

```bash theme={null}
export SAKAWAVE_API_KEY='sk_test_replace_with_your_key'
export SAKAWAVE_BASE_URL='https://sandbox-api.sakawave.example'
```

You need an NGN account that is active and debitable, and not a customer wallet.

## 1. Find the product

Three reads, top down.

```bash theme={null}
# What kinds of bill exist
curl --silent --url "$SAKAWAVE_BASE_URL/v1/bill-payments/groups" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" | jq '.items[] | {id, name}'

# Billers in one group
curl --silent --url "$SAKAWAVE_BASE_URL/v1/bill-payments/groups/DISCO/billers" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" | jq '.items[] | {id, name}'

# What that biller sells
curl --silent --url "$SAKAWAVE_BASE_URL/v1/bill-payments/groups/DISCO/billers/EXAMPLE_DISCO/packages" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" | jq '.items[] | {id, name, amount}'
```

A package `amount` is set for a fixed-price product — a data bundle — and null when the customer chooses.

<Note>
  Catalog `id`s are Sakawave ULIDs and are the only accepted addresses. Read them from the catalog and store them; a slug is display text.
</Note>

## 2. Validate the customer

This is the step that matters.

<CodeGroup>
  ```bash cURL theme={null}
  ENQUIRY=$(curl --silent --show-error --fail-with-body \
    --request POST \
    --url "$SAKAWAVE_BASE_URL/v1/bill-payments/enquiries" \
    --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
    --header "Content-Type: application/json" \
    --data '{
      "customerId": "04117997788",
      "billerId": "01KZXPYGX5VZXDH9VHJ4YTPCC1",
      "packageId": "01KZXPYGX5VZXDH9VHJ4YTPCD2",
      "amount": "5000.00"
    }')

  ENQUIRY_ID=$(printf '%s' "$ENQUIRY" | jq --raw-output '.id')
  ```

  ```ts Node.js theme={null}
  const enquiry = await post('/v1/bill-payments/enquiries', {
    customerId: meterNumber,
    billerId: '01KZXPYGX5VZXDH9VHJ4YTPCC1',
    packageId: '01KZXPYGX5VZXDH9VHJ4YTPCD2',
    amount: '5000.00',
  });

  // Show this to the payer before taking money. It is the only check that the
  // meter number was typed correctly.
  await confirmWithUser({
    name: enquiry.customer.customerName,
    address: enquiry.customer.address,
    youPay: enquiry.totalDebit,
  });
  ```
</CodeGroup>

```json theme={null}
{
  "id": "01K2Z3AB4W5X6Y7Z8ABCDEFGHJ",
  "customer": { "customerName": "ADA OKAFOR", "address": "14 Marina Road", "canVend": true, "minimumVend": "500.00" },
  "minimumAmount": "500.00",
  "charge": "100.00",
  "commission": "0.00",
  "totalDebit": "5100.00",
  "expiresAt": "2026-08-14T09:20:00.000Z"
}
```

Three things to do with it:

1. **Show `customer.customerName` to the payer.** A wrong meter number is a valid meter number belonging to someone else.
2. **Show `totalDebit`, not `amount`.** The charge is on top.
3. **Move quickly.** `expiresAt` is ten minutes out.

Note the amount format: `"5000.00"`, a decimal string. Not `"500000"`. Bill payments are the one service in this API that does not use minor units.

## 3. Pay

```bash theme={null}
curl --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/bill-payments" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"enquiryId\": \"$ENQUIRY_ID\",
    \"accountId\": \"$NGN_ACCOUNT_ID\",
    \"amount\": \"5000.00\",
    \"externalReference\": \"bill_20260814_001\",
    \"customerName\": \"Ada Okafor\",
    \"phoneNumber\": \"+2348012345678\"
  }"
```

The account is debited immediately. What comes back tells you how far delivery got:

```json theme={null}
{
  "id": "01K2Z3AB4X5Y6Z7A8BCDEFGHJK",
  "status": "confirmed",
  "amount": "5000.00",
  "totalDebit": "5100.00",
  "vendStatus": "SUCCESSFUL",
  "tokenData": { "token": "1234-5678-9012-3456-7890", "units": "31.4" },
  "customerReferenceLast4": "7788"
}
```

`submitted` or `pending` is just as normal as `confirmed` — the biller may take a moment.

## 4. Deliver the token

Where a biller issues a credential — a prepaid electricity token, most often — it arrives in `tokenData` once the payment is `confirmed`.

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

if (payment.status === 'confirmed' && payment.tokenData) {
  await deliverToCustomer(payment.tokenData);   // and store it
}
```

<Warning>
  Store the token. It is not re-issued, and a customer who loses it has lost the value you bought them.
</Warning>

## Four things to know

1. **Amounts are decimals here.** `"5000.00"`, not `"500000"`.
2. **The success state is `confirmed`**, not `completed`.
3. **A failure refunds itself.** Sakawave reverses the debit; you do not claim it back.
4. **An enquiry lasts ten minutes** and is spent by one payment.

## If it failed

| Status | `code` | What to do |
| - | - | - |
| `400` | `customer_lookup_failed` | The biller does not recognise the reference. The biller's own message is passed through. |
| `409` | `bill_enquiry_expired` | Older than ten minutes, or already spent. Enquire again. |
| `409` | `amount_below_minimum` | Below the biller's `minimumVend`, which the enquiry returned. |
| `409` | `bill_amount_mismatch` | The amount disagrees with a fixed-price package. |
| `409` | `bill_payment_account_ineligible` | The account is inactive, not debitable, a wallet, or not NGN. |
| `409` | `insufficient_funds` | The account cannot cover `totalDebit` — remember the charge. |
| `503` | `catalog_unavailable` | The catalog is empty and could not be refreshed. |

## Next

* [Airtime, data, and utilities](/services/bill-payments/airtime-data-and-utilities) — the families behave differently
* [Use cases](/services/bill-payments/use-cases) — retries, reconciliation, token delivery


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