---
title: >-
  Bill every call exactly once: a prepaid voice wallet ledger that stays correct
  under concurrency
description: >-
  Why a mutable balance column is wrong for voice billing, and how an
  append-only ledger with idempotency keys makes call debits correct and
  auditable.
summary: >-
  Every completed call must debit the wallet exactly once. An append-only ledger
  with idempotency keys solves concurrency, retries, and audit in one schema
  change.
date: 2026-07-11T00:00:00.000Z
type: blog
---


## Summary

Every minute of a call that completes must debit the wallet exactly once: not zero times, not twice. A mutable `balance` column cannot guarantee this under concurrent calls, network retries, or a database under load. The correct design is an append-only ledger where balance is derived from `SUM(amount)`, debits are idempotent via a unique `idempotency_key`, and top-up state is modelled explicitly as pending/confirmed/failed, so a failed M-Pesa STK push never partially credits anything.

## The wrong way: mutable balance and its race conditions

The simplest wallet schema is a single integer column:

```sql
CREATE TABLE wallets (
  workspace_id UUID PRIMARY KEY,
  balance_kes_millicents BIGINT NOT NULL DEFAULT 0
);
```

A call completes. Your `call.completed` webhook handler fires and runs:

```sql
UPDATE wallets
SET balance_kes_millicents = balance_kes_millicents - :debit
WHERE workspace_id = :workspace_id;
```

This looks fine in a single-threaded test. Under concurrent calls it is not. Consider two calls completing simultaneously on the same workspace: both handlers read `balance = 10000`, both compute `10000 - 400 = 9600`, and both write `9600`. One debit vanished. The wallet now shows KES 9.60 instead of KES 9.20, and your audit trail has no record of why.

The standard fix, `SELECT ... FOR UPDATE`, eliminates the race but serialises all concurrent debit operations for the same workspace. Under load this creates lock contention that degrades database performance and increases call-completion latency. It also does nothing to protect against duplicate webhook delivery, which is an at-least-once guarantee that every event system provides.

## The append-only ledger: correctness by design

The correct schema stores every financial event as an immutable row:

```sql
CREATE TABLE wallet_entries (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id  UUID NOT NULL REFERENCES workspaces(id),
  idempotency_key TEXT NOT NULL,
  entry_type    TEXT NOT NULL CHECK (entry_type IN ('debit', 'credit', 'reversal')),
  amount_kes_millicents BIGINT NOT NULL,
  call_id       UUID REFERENCES calls(id),
  description   TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  UNIQUE (workspace_id, idempotency_key)
);
```

Balance is not stored; it is derived:

```sql
SELECT COALESCE(SUM(amount_kes_millicents), 0) AS balance
FROM wallet_entries
WHERE workspace_id = $1;
```

For O(1) balance reads without scanning the full ledger, maintain a materialised running total:

```sql
CREATE MATERIALIZED VIEW wallet_balances AS
SELECT
  workspace_id,
  SUM(amount_kes_millicents) AS balance_kes_millicents
FROM wallet_entries
GROUP BY workspace_id;

CREATE UNIQUE INDEX ON wallet_balances (workspace_id);
```

Refresh it after each insert, either with a trigger or in the same transaction:

```sql
REFRESH MATERIALIZED VIEW CONCURRENTLY wallet_balances;
```

`CONCURRENTLY` means readers are never blocked during the refresh. The view stays consistent with the ledger: it is not a cache that can drift, it is a deterministic projection.

## Schema note: millicents avoid floating-point drift

All amounts in the ledger are stored as integer millicents (thousandths of a KES cent). KES 3.00/min expressed as a per-second rate is:

```
KES 3.00 / 60 seconds = KES 0.0500 per second
```

Storing this as a float accumulates rounding error over thousands of entries. Instead, compute in integer arithmetic:

```go
// Sautikit bills per second from the moment the call connects.
// KES 3.00/min = 300 KES cents per minute
// Let's be precise:
//   KES 3.00 = 300 KES cents = 300_000 millicents per 60 seconds
//   per second = 300_000 / 60 = 5_000 millicents/sec (exact, no truncation)

const ratePerSecondMillicents = 300_000 / 60 // = 5000

func debitAmount(durationSeconds int) int64 {
    if durationSeconds <= 0 {
        return 0
    }
    return int64(durationSeconds) * ratePerSecondMillicents
}
```

A 1-second call costs 5 000 millicents (KES 0.05). A 25-second call costs 125 000 millicents (KES 1.25). A 61-second call is billed as 61 seconds; Sautikit bills per second from the moment the call connects, with no minimum. Write a test for the boundary:

```go
func TestDebitAmountBoundaries(t *testing.T) {
    cases := []struct {
        seconds  int
        expected int64
    }{
        {0, 0},        // zero-duration call: no charge
        {1, 5000},     // 1 second: 1 * 5000 millicents
        {6, 30000},    // 6 * 5000 millicents
        {60, 300000},  // 60 * 5000
        {61, 305000},  // 61 * 5000
    }
    for _, c := range cases {
        got := debitAmount(c.seconds)
        if got != c.expected {
            t.Errorf("debitAmount(%d) = %d, want %d", c.seconds, got, c.expected)
        }
    }
}
```

Run this test after any change to the billing arithmetic. At KES 3.00/min the per-second rate is exactly 5 000 millicents, with no truncation error. Inbound calls have a rate of KES 0/min and are never debited.

## Debiting a call: from `call.completed` webhook to ledger entry

Your `call.completed` webhook handler receives the call ID and duration. The debit must be idempotent: if the webhook fires twice (at-least-once delivery), the ledger must record only one entry.

The `idempotency_key` is the compound of the event type and call ID. Use `INSERT ... ON CONFLICT DO NOTHING`:

```go
func (s *BillingService) HandleCallCompleted(ctx context.Context, callID string, durationSec int) error {
    debit := debitAmount(durationSec) // negative for a debit
    idempotencyKey := "call.completed:" + callID

    _, err := s.db.ExecContext(ctx, `
        INSERT INTO wallet_entries
          (workspace_id, idempotency_key, entry_type, amount_kes_millicents, call_id, description)
        SELECT
          c.workspace_id,
          $2,
          'debit',
          -$3,
          c.id,
          'Call completed: ' || c.duration_seconds || 's'
        FROM calls c
        WHERE c.id = $1
        ON CONFLICT (workspace_id, idempotency_key) DO NOTHING
    `, callID, idempotencyKey, debit)
    if err != nil {
        return fmt.Errorf("insert ledger entry: %w", err)
    }

    // Refresh the materialised view so balance reads are current.
    _, err = s.db.ExecContext(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY wallet_balances`)
    return err
}
```

The `ON CONFLICT DO NOTHING` clause makes duplicate webhook delivery safe. The first delivery inserts the row; every subsequent delivery hits the unique constraint and silently does nothing. No distributed lock required.

## M-Pesa STK push top-ups: pending, confirmed, failed

The wallet's credit side introduces a second source of correctness risk: an M-Pesa STK push that times out or is declined by the user must never partially credit the wallet.

Model top-up state explicitly:

```sql
CREATE TABLE wallet_topups (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id         UUID NOT NULL REFERENCES workspaces(id),
  phone_e164           TEXT NOT NULL,           -- must include + prefix
  amount_kes_minor     INT NOT NULL,             -- KES cents
  checkout_request_id  TEXT UNIQUE,             -- Daraja CheckoutRequestID
  mpesa_receipt        TEXT,                    -- populated on confirmation
  state                TEXT NOT NULL DEFAULT 'pending'
                       CHECK (state IN ('pending', 'confirmed', 'failed')),
  initiated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  resolved_at          TIMESTAMPTZ
);
```

The top-up flow is two-step. First, initiate the STK push and record the `CheckoutRequestID`:

```bash
# Step 1: initiate via Sautikit wallet API (which calls Daraja on your behalf)
curl -s -X POST https://api.sautikit.com/v1/topups \
  -H "Authorization: Bearer $SAUTIKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_e164": "+254712345678",
    "amount_kes": 2000
  }'
```

Response:

```json
{
  "topup_id": "tpu_abc123",
  "checkout_request_id": "ws_CO_17062026123456789",
  "state": "pending",
  "message": "STK push sent. Prompt will appear on the registered phone."
}
```

The `phone_e164` field requires the `+` prefix. Daraja returns a `CheckoutRequestID`; store it in `wallet_topups.checkout_request_id`.

Sautikit polls Daraja's `MpesaExpressQuery` endpoint internally and fires a `wallet.top_up` webhook when the top-up is confirmed. If the STK push fails, the top-up moves to a failed state internally and no webhook is emitted. On confirmation, insert a credit entry:

```go
func (s *BillingService) HandleTopupConfirmed(ctx context.Context, topupID string, amountKESMinor int) error {
    idempotencyKey := "wallet.top_up:" + topupID
    amountMillicents := int64(amountKESMinor) * 1000 // KES cents → millicents

    _, err := s.db.ExecContext(ctx, `
        WITH topup AS (
            UPDATE wallet_topups
            SET state = 'confirmed', resolved_at = NOW()
            WHERE id = $1 AND state = 'pending'
            RETURNING workspace_id
        )
        INSERT INTO wallet_entries
          (workspace_id, idempotency_key, entry_type, amount_kes_millicents, description)
        SELECT workspace_id, $2, 'credit', $3, 'M-Pesa top-up confirmed: ' || $1
        FROM topup
        ON CONFLICT (workspace_id, idempotency_key) DO NOTHING
    `, topupID, idempotencyKey, amountMillicents)
    return err
}
```

The `UPDATE wallet_topups ... WHERE state = 'pending'` guard ensures that a duplicate `wallet.top_up` event does not credit twice. The `ON CONFLICT` covers any remaining edge case. A failed top-up never reaches this handler; it updates `state = 'failed'` and does not insert any ledger row.

## Reconciliation: detecting drift between call records and ledger

Ledgers accumulate subtle bugs over time: a webhook that fired but hit a database timeout before inserting, a billing worker that crashed mid-run, a one-off script that deducted without using the idempotency guard. Detect drift with a nightly reconciliation query:

```sql
-- Calls that completed but have no corresponding ledger debit
SELECT c.id, c.workspace_id, c.duration_seconds, c.completed_at
FROM calls c
LEFT JOIN wallet_entries we
    ON we.call_id = c.id AND we.entry_type = 'debit'
WHERE c.status = 'completed'
  AND c.completed_at < NOW() - INTERVAL '10 minutes'  -- allow processing lag
  AND we.id IS NULL
ORDER BY c.completed_at DESC;
```

Any row returned by this query represents a call that was completed but not debited. The remediation is to insert the missing debit manually; the idempotency key scheme means doing so is safe even if the entry already exists (it will just do nothing).

Run the reconciliation daily and alert if the count exceeds a threshold (zero expected under normal operation). The query is fast if `wallet_entries` has an index on `(call_id)`.

## The Sautikit wallet in production

Sautikit's own prepaid KES wallet follows this ledger model. The `GET /v1/wallet/balance` endpoint returns the current balance derived from the ledger, not a cached mutable field:

```bash
curl -s https://api.sautikit.com/v1/wallet/balance \
  -H "Authorization: Bearer $SAUTIKIT_API_KEY"
```

```json
{
  "balance_minor": 85000,
  "currency": "KES",
  "status": "healthy",
  "server_time": "2026-07-11T14:22:00Z"
}
```

`balance_minor` is KES cents. `85000` = KES 850. The `status` field is `healthy` when balance is above the low-balance threshold, `warning` when it is within 20% of a configurable floor, and `critical` when it is below the floor.

The `GET /v1/wallet/statements` endpoint returns paginated ledger rows (one row per debit and credit) so you can reconcile your own records against Sautikit's at any time.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Wire your `call.completed` and `wallet.top_up` webhooks to an append-only ledger with idempotency keys.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Further reading

- [Sautikit wallet and top-up concepts](/developers/concepts/wallet): wallet states, top-up flow, and low-balance webhooks
- [Pricing: per-minute rates and billing increments](/pricing): exact KES rates and billing details
- [Calls API reference](/developers/api#calls): `call.completed` event payload and field definitions
- [Martin Kleppmann on accounting for computer scientists](https://martin.kleppmann.com/2011/03/07/accounting-for-computer-scientists.html): the theoretical foundation for append-only financial ledgers
- [Safaricom Daraja STK push documentation](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate): the M-Pesa STK push API that backs Sautikit wallet top-ups
