Guide · 04 / 10

Collections

Accept a payment two ways — direct API and hosted checkout — plus refunds.

A collection is a payment you pull from a payer. There are two ways to accept one:

  • Option A — Direct: you already know the rail and hold the payer's reference (phone number / card token / account token). POST /collections.
  • Option B — Hosted checkout session: you don't want to handle payment details. Create a session, hand its client_token to a payer-facing surface, and let the payer pick the rail and pay.

Both settle into the same Transaction and emit the same transaction.* webhooks.

Amounts are always integer minor units (KES 1,500.00 = 150000). Rails are mobile_money, card, bank, ussd, qr.

The Transaction object

Both flows return a Transaction:

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "merchant_id": "8f3a2b10-1c9e-4c2a-9b2f-3e6d2a1e9c11",
  "sub_merchant_id": null,
  "type": "collection",
  "rail": "mobile_money",
  "status": "pending",
  "amount": 150000,
  "currency": "KES",
  "parent_transaction_id": null,
  "provider_reference": null,
  "reference": "order_10293",
  "action_url": null,
  "ussd_code": null,
  "metadata": null,
  "created_at": "2026-08-15T09:15:00.000Z",
  "updated_at": "2026-08-15T09:15:00.000Z"
}

Key fields:

  • statuspendingsuccess / failed, and reversed after a refund. A collection is almost always pending on creation and reaches its terminal state asynchronously once the rail confirms. Do not treat the create response as final — wait for the transaction.success / transaction.failed webhook, or poll GET /collections/{id}.
  • action_url — set when the payer must complete a challenge such as card 3-D Secure. Redirect the payer here.
  • ussd_code — set when the payer must dial a USSD string (mobile-money fallback).
  • provider_reference — the rail's own reference (e.g. an M-PESA receipt), populated once the rail responds.

Option A — Direct (POST /collections)

Use this when you have the payer's details server-side.

Request body (CollectionCreateRequest):

FieldRequiredNotes
amountyesMinor units, >= 1.
currencyyesISO-4217, 3 letters.
railyesmobile_money, card, bank, ussd, or qr.
payer_phoneconditionallyRequired when rail is mobile_money or ussd.
payer_card_tokenconditionallyTokenized card reference. Required when rail = card. Never send a raw PAN.
payer_account_refconditionallyTokenized bank account reference. Required when rail = bank.
sub_merchant_idnoAttribute the collection to a sub-merchant.
referencenoYour order/reference string; echoed back on the transaction.
hold_in_escrownotrue lands the merchant leg in the pending (escrow) bucket for later release. See Split & escrow.

Example (headers abbreviated — see Authentication for signing):

curl -sS https://sandbox.api.wasaapay.com/v1/collections \
  -H "X-WasaaPay-Key: $KEY" \
  -H "X-WasaaPay-Timestamp: $TS" \
  -H "X-WasaaPay-Signature: $SIG" \
  -H "Idempotency-Key: 3f9a1c2e-8b0d-4d1a-9c77-0b2e1f5a6c34" \
  -H "Content-Type: application/json" \
  --data '{
    "amount": 150000,
    "currency": "KES",
    "rail": "mobile_money",
    "payer_phone": "254700000001",
    "reference": "order_10293"
  }'

Returns 200 with the Transaction (status usually pending). Retrying with the same Idempotency-Key and body returns the original transaction rather than starting a second charge.

Card example — pass a token from your certified card tokenizer, never card numbers:

{ "amount": 150000, "currency": "KES", "rail": "card", "payer_card_token": "tok_success" }

Check status

curl -sS https://sandbox.api.wasaapay.com/v1/collections/3fa85f64-5717-4562-b3fc-2c963f66afa6 \
  -H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG"

GET /collections/{id} is a read (any scope with read) and needs no idempotency key. You can also list and filter with GET /transactions?status=&rail=&date_from=&date_to=.

Option B — Hosted checkout session

When you'd rather not touch payment details, open a checkout session. You set the amount and the rails the payer may choose from; WasaaPay mints a one-time client_token that authorizes a payer-facing surface to view and pay the session. The payer picks the rail — you never handle their phone/card.

The flow has three steps and two different authorizations:

  merchant (API key)                     payer surface (client_token)
  ──────────────────                     ────────────────────────────
  1. POST /checkout-sessions   ─────────▶ returns client_token (once)
                                          2. GET  /checkout-sessions/{id}
                                          3. POST /checkout-sessions/{id}/pay
                                             (payer chooses rail) ──▶ Transaction

1. Create the session (merchant-authorized)

POST /checkout-sessions — signed with your merchant API key, plus an Idempotency-Key.

Request (CheckoutSessionCreateRequest):

FieldRequiredNotes
amountyesMinor units, >= 1.
currencyyesISO-4217.
referencenoEchoed on the resulting transaction.
sub_merchant_idnoAttribute to a sub-merchant.
allowed_railsnoRails the payer may choose. Defaults to all rails.
curl -sS https://sandbox.api.wasaapay.com/v1/checkout-sessions \
  -H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
  -H "Idempotency-Key: 9b1f0c34-2a77-4e0d-8b6a-1c2e3f4a5b6c" \
  -H "Content-Type: application/json" \
  --data '{ "amount": 150000, "currency": "KES", "reference": "order_10293", "allowed_rails": ["mobile_money","card"] }'

Response (CheckoutSession — the only time client_token is returned):

{
  "data": {
    "id": "b2d9c1f0-3e77-4a2b-9c11-8f3a2b101c9e",
    "merchant_id": "8f3a2b10-1c9e-4c2a-9b2f-3e6d2a1e9c11",
    "sub_merchant_id": null,
    "amount": 150000,
    "currency": "KES",
    "reference": "order_10293",
    "allowed_rails": ["mobile_money", "card"],
    "status": "open",
    "client_token": "cht_5f0e...opaque-secret...",
    "collection_intent_id": null,
    "expires_at": "2026-08-15T10:15:00.000Z",
    "created_at": "2026-08-15T09:15:00.000Z",
    "updated_at": "2026-08-15T09:15:00.000Z"
  },
  "error": null,
  "meta": { "request_id": "req_9f2c1a" }
}

Treat client_token as a bearer secret. Hand it to the payer surface (a hosted page, your own front end, etc.) — do not ship your merchant API key there.

2. Read the session (payer-authorized)

The payer surface renders the amount and rail choices using the public view, authorized by the client_token — via the X-Checkout-Token header or a client_token query parameter. The token is never echoed back.

curl -sS "https://sandbox.api.wasaapay.com/v1/checkout-sessions/b2d9c1f0-3e77-4a2b-9c11-8f3a2b101c9e" \
  -H "X-Checkout-Token: cht_5f0e...opaque-secret..."
{
  "data": {
    "id": "b2d9c1f0-3e77-4a2b-9c11-8f3a2b101c9e",
    "merchant_id": "8f3a2b10-1c9e-4c2a-9b2f-3e6d2a1e9c11",
    "amount": 150000,
    "currency": "KES",
    "reference": "order_10293",
    "allowed_rails": ["mobile_money", "card"],
    "status": "open",
    "expires_at": "2026-08-15T10:15:00.000Z"
  },
  "error": null,
  "meta": { "request_id": "req_..." }
}

A session past expires_at reads back as expired.

3. Pay the session (payer-authorized)

The payer picks a rail from allowed_rails and supplies the matching reference. POST /checkout-sessions/{id}/pay is authorized by the client_token and requires an Idempotency-Key.

Request (CheckoutSessionPayRequest): rail plus the field that rail needs — payer_phone (mobile money / USSD), payer_card_token (card), or payer_account_ref (bank).

curl -sS "https://sandbox.api.wasaapay.com/v1/checkout-sessions/b2d9c1f0-3e77-4a2b-9c11-8f3a2b101c9e/pay" \
  -H "X-Checkout-Token: cht_5f0e...opaque-secret..." \
  -H "Idempotency-Key: e1c2a3b4-5d6f-4708-9a1b-2c3d4e5f6071" \
  -H "Content-Type: application/json" \
  --data '{ "rail": "mobile_money", "payer_phone": "254700000001" }'

This creates the underlying collection (through the same path as POST /collections), marks the session completed, and returns the Transaction. As with a direct collection, action_url / ussd_code are populated when a payer challenge is required, and the final outcome arrives via the transaction.* webhook. A completed or expired session rejects new pays.

SDK note. The checkout-session endpoints are part of the HTTP API but are not yet exposed as methods in @wasaapay/api-client, and the mobile SDKs use a different (publishable-key) model — see SDKs. For now, drive sessions over raw HTTP as shown above.

Refunds

Refund a successful collection with POST /collections/{id}/refunds. Omit the body (or amount) to refund the full remaining amount; send amount (minor units) for a partial refund. An Idempotency-Key is required.

Request (RefundCreateRequest, body optional):

FieldNotes
amountMinor units. Omit / null = full remaining refund. Must not exceed the original amount minus prior refunds.
reasonOptional, up to 500 chars; stored on the refund transaction.
# Partial refund of 500.00 KES
curl -sS https://sandbox.api.wasaapay.com/v1/collections/3fa85f64-5717-4562-b3fc-2c963f66afa6/refunds \
  -H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
  -H "Idempotency-Key: 7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f" \
  -H "Content-Type: application/json" \
  --data '{ "amount": 50000, "reason": "Customer returned one of two items." }'

The refund comes back as a Transaction with type = "refund" and parent_transaction_id set to the original collection's ID. A refund reversing the full amount moves the original collection to reversed. A full-scope or write-scope key is required (refunds are a write operation).