Guide · 06 / 10
Split & escrow
Divide a collection across recipients, hold shares in escrow, and release or return them via the API.
Split rules describe how a collection's value is divided among recipients. Escrow lets a recipient's share be held back until a condition is met, instead of settling immediately.
Split rules
Create a reusable rule with POST /split-rules (a write operation; no
idempotency key required).
Request (SplitRuleCreateRequest):
| Field | Required | Notes |
|---|---|---|
name | yes | Human label for the rule. |
rule_type | yes | percentage, flat, or tiered. |
currency | for flat | ISO-4217 the flat amounts are denominated in. Omit for percentage rules. |
recipients | yes | 1+ recipients (below). |
Each recipient (SplitRuleRecipient):
| Field | Required | Notes |
|---|---|---|
recipient_type | yes | platform, sub_merchant, or tax. |
recipient_id | for sub_merchant | UUID of the sub-merchant receiving the share. |
value | yes | Percentage 0–100 when rule_type = percentage, otherwise a flat minor-unit amount. |
escrow | no | true holds this recipient's share (default false). |
escrow_release_condition | no | manual, auto_timeout, or delivery_confirmation. |
escrow_timeout_hours | no | Used with auto_timeout. |
Example — platform keeps 10%, the sub-merchant gets 90% held in escrow until delivery is confirmed:
curl -sS https://sandbox.api.wasaapay.com/v1/split-rules \
-H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
-H "Content-Type: application/json" \
--data '{
"name": "Standard marketplace split",
"rule_type": "percentage",
"recipients": [
{ "recipient_type": "platform", "value": 10, "escrow": false },
{ "recipient_type": "sub_merchant", "recipient_id": "5f0e1d2c-...", "value": 90,
"escrow": true, "escrow_release_condition": "delivery_confirmation" }
]
}'
The response is a SplitRule with an id and active: true. List rules with
GET /split-rules (optionally ?active=true) and fetch one with
GET /split-rules/{id}.
A percentage rule's
values should describe a complete division of the collection (platform + recipients = 100%). Validation errors come back as422 VALIDATION_ERRORwith the offending fields indetails.
Escrow
Escrow holds a recipient's share in a wallet's pending bucket (it shows up as
pending_balance, not available_balance) until you settle it. WasaaPay exposes
four merchant-scoped endpoints to inspect and settle holds:
| Endpoint | Purpose |
|---|---|
GET /escrow?transaction_id=&status=&sub_merchant_id= | Find / list your holds (newest first). |
GET /escrow/{id} | Read one hold. |
POST /escrow/{id}/release | Release funds to the recipient (partial or full). |
POST /escrow/{id}/return | Return funds to your available balance (partial or full). |
They authenticate with the same API key + HMAC signature as every other /v1
resource (see Authentication); the gateway injects your
merchant identity. All amounts are integer minor units.
What creates a releasable hold (funding model)
Common misconception. Setting
hold_in_escrow: trueon a collection does not create a releasable escrow hold. That flag is only a ledger-bucket hint — it parks the merchant leg in the pending bucket, but mints no hold, noescrow_hold_id, and no lifecycle you can act on.
A releasable hold is created only when a collection is settled through a split
rule that has an escrow recipient — for example a professional booked as a
sub-merchant whose 90% share is held until delivery is confirmed. The hold is
minted asynchronously, after the collection reaches transaction.success,
so there is no escrow_hold_id on the collection response.
Concrete flow:
-
Create a split rule with an escrow recipient (as above):
{ "name": "Booking — hold the professional's share", "rule_type": "percentage", "recipients": [ { "recipient_type": "platform", "value": 10, "escrow": false }, { "recipient_type": "sub_merchant", "recipient_id": "5f0e1d2c-...", "value": 90, "escrow": true, "escrow_release_condition": "delivery_confirmation" } ] } -
Collect against it with
POST /collections, passing your ownreference(it is carried onto the hold and echoed on the escrow webhooks):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: $(uuidgen)" -H "Content-Type: application/json" \ --data '{ "amount": 150000, "currency": "KES", "rail": "mobile_money", "payer_phone": "2547XXXXXXXX", "sub_merchant_id": "5f0e1d2c-...", "reference": "order-123" }' -
On
transaction.success, a hold is minted. Retrieve itsid(escrow_hold_id) either way:- from the
escrow.heldwebhook (it carries yourreference), or - by polling
GET /escrow?transaction_id=<collection id>:
curl -sS "https://sandbox.api.wasaapay.com/v1/escrow?transaction_id=3fa85f64-..." \ -H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" - from the
The hold object
GET /escrow/{id} (and the list) returns an EscrowHold:
{
"data": {
"id": "7c9e6679-...",
"transaction_id": "3fa85f64-...",
"split_execution_id": "b2d1c3a4-...",
"merchant_id": "8f3a2b10-...",
"recipient_id": "5f0e1d2c-...",
"sub_merchant_id": "5f0e1d2c-...",
"wallet_id": "9a1b2c3d-...",
"amount": 135000,
"released_amount": null,
"returned_amount": 0,
"remaining_amount": 135000,
"currency": "KES",
"status": "held",
"reference": "order-123",
"release_condition": "delivery_confirmation",
"auto_release_at": null,
"released_at": null,
"created_at": "2026-08-15T09:15:00.000Z",
"updated_at": "2026-08-15T09:15:00.000Z"
},
"error": null,
"meta": { "request_id": "req_9f2c1a" }
}
The money model is three numbers that always satisfy
remaining_amount = amount − released_amount − returned_amount:
| Field | Meaning |
|---|---|
amount | Total originally held. |
released_amount | Cumulative released to the recipient. null while the hold is untouched; 0+ once any settlement has happened. |
returned_amount | Cumulative returned to your available balance. |
remaining_amount | Still held and actionable. |
status moves through:
| Status | Meaning |
|---|---|
held | Full amount still held. |
partially_released | Some released or returned; remaining_amount > 0. |
released | Fully released to the recipient. |
returned | Fully returned to you. |
disputed | A dispute is open against the hold. |
sub_merchant_idis an alias ofrecipient_id(the escrowed recipient), under the name theGET /escrowlist filter uses. Filter the list bytransaction_id,status, and/orsub_merchant_id.
Release (to the recipient)
POST /escrow/{id}/release moves held funds to the recipient's available
balance. Release the full remaining amount (empty body) or a partial,
per-milestone amount (amount in minor units). This endpoint is also the
programmatic trigger for a delivery_confirmation hold.
curl -sS https://sandbox.api.wasaapay.com/v1/escrow/7c9e6679-.../release \
-H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
--data '{ "amount": 2000, "reason": "delivery confirmed", "reference": "order-123" }'
| Field | Required | Notes |
|---|---|---|
amount | no | Minor units to release; omit for the full remaining. Must not exceed remaining_amount. |
reason | no | ≤ 2000 chars, nullable. |
reference | no | ≤ 140 chars, nullable — persisted on the hold and echoed on webhooks. |
A partial release leaves the hold partially_released; releasing the remainder
moves it to released. The response is the updated EscrowHold, and an
escrow.released webhook fires with the new cumulative released_amount /
remaining_amount.
Return (to your balance — for refunds)
POST /escrow/{id}/return moves held funds to your (the merchant's) available
balance — NOT back to the card / mobile-money payer. Use it when a booking is
cancelled or a dispute goes the buyer's way: the funds return to you, and you
then refund the payer out of band (for example with
POST /collections/{id}/refunds, see Collections).
curl -sS https://sandbox.api.wasaapay.com/v1/escrow/7c9e6679-.../return \
-H "X-WasaaPay-Key: $KEY" -H "X-WasaaPay-Timestamp: $TS" -H "X-WasaaPay-Signature: $SIG" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
--data '{ "amount": 1500, "reason": "refund agreed" }'
return takes amount (omit for the full remaining) and reason (≤ 2000 chars,
nullable) — there is no reference field. The response is the updated
EscrowHold, and an escrow.returned webhook fires.
Idempotency
release and return both require an Idempotency-Key header — a missing
key is rejected 422 VALIDATION_ERROR. Retrying with the same key returns the
original result (a 200, never a second movement of funds), so settling is
retry-safe. Reusing a key with a different body is 409 IDEMPOTENCY_CONFLICT.
GET /escrow and GET /escrow/{id} need no idempotency key.
Errors
| Status | Code | When |
|---|---|---|
| 422 | VALIDATION_ERROR | Bad amount, an amount over remaining_amount, or a missing Idempotency-Key. |
| 409 | IDEMPOTENCY_CONFLICT | Same key with a different payload, or a request still in flight. |
| 404 | RESOURCE_NOT_FOUND | Unknown hold, or one owned by another merchant (its existence is never leaked). |
| 401 | AUTH_INVALID_KEY | Missing or invalid key / signature. |
Release conditions
A hold's release_condition records how it is meant to be released:
| Condition | Releases when |
|---|---|
manual | You explicitly call POST /escrow/{id}/release (or release via the dashboard). |
auto_timeout | escrow_timeout_hours elapse (surfaced as auto_release_at on the hold). |
delivery_confirmation | Delivery is confirmed — which you now signal by calling POST /escrow/{id}/release. |
The public release endpoint now doubles as the delivery_confirmation trigger:
confirming delivery in your system means calling release on the hold.
Escrow webhooks
Each hold emits escrow.* webhooks you can use to
keep your ledger in step:
escrow.held— the hold was minted (carriesescrow_hold_idand yourreference).escrow.released— funds moved to the recipient (carries cumulativereleased_amountandremaining_amount).escrow.returned— funds returned to your balance (carriesreleased_amount/remaining_amount).
All three payloads now additionally carry reference (the originating collection
reference, null if there was none), released_amount (cumulative, nullable) and
remaining_amount (nullable). See Webhooks → escrow events
for the full payload.
Replay caveat — do not reject escrow retries as stale. Webhook delivery retries reuse the original
X-WasaaPay-TimestampandX-WasaaPay-Signature(a stable replay key), so a retry delivered hours after the event still carries the original timestamp. A strict ±300s timestamp check will wrongly reject a legitimate retry. Dedupe by eventid(or widen your timestamp-acceptance window to the maximum retry horizon) rather than rejecting on age — see Webhooks → replay protection.