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_tokento 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:
status—pending→success/failed, andreversedafter a refund. A collection is almost alwayspendingon creation and reaches its terminal state asynchronously once the rail confirms. Do not treat the create response as final — wait for thetransaction.success/transaction.failedwebhook, or pollGET /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):
| Field | Required | Notes |
|---|---|---|
amount | yes | Minor units, >= 1. |
currency | yes | ISO-4217, 3 letters. |
rail | yes | mobile_money, card, bank, ussd, or qr. |
payer_phone | conditionally | Required when rail is mobile_money or ussd. |
payer_card_token | conditionally | Tokenized card reference. Required when rail = card. Never send a raw PAN. |
payer_account_ref | conditionally | Tokenized bank account reference. Required when rail = bank. |
sub_merchant_id | no | Attribute the collection to a sub-merchant. |
reference | no | Your order/reference string; echoed back on the transaction. |
hold_in_escrow | no | true 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):
| Field | Required | Notes |
|---|---|---|
amount | yes | Minor units, >= 1. |
currency | yes | ISO-4217. |
reference | no | Echoed on the resulting transaction. |
sub_merchant_id | no | Attribute to a sub-merchant. |
allowed_rails | no | Rails 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):
| Field | Notes |
|---|---|
amount | Minor units. Omit / null = full remaining refund. Must not exceed the original amount minus prior refunds. |
reason | Optional, 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).