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):

FieldRequiredNotes
nameyesHuman label for the rule.
rule_typeyespercentage, flat, or tiered.
currencyfor flatISO-4217 the flat amounts are denominated in. Omit for percentage rules.
recipientsyes1+ recipients (below).

Each recipient (SplitRuleRecipient):

FieldRequiredNotes
recipient_typeyesplatform, sub_merchant, or tax.
recipient_idfor sub_merchantUUID of the sub-merchant receiving the share.
valueyesPercentage 0–100 when rule_type = percentage, otherwise a flat minor-unit amount.
escrownotrue holds this recipient's share (default false).
escrow_release_conditionnomanual, auto_timeout, or delivery_confirmation.
escrow_timeout_hoursnoUsed 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 as 422 VALIDATION_ERROR with the offending fields in details.

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:

EndpointPurpose
GET /escrow?transaction_id=&status=&sub_merchant_id=Find / list your holds (newest first).
GET /escrow/{id}Read one hold.
POST /escrow/{id}/releaseRelease funds to the recipient (partial or full).
POST /escrow/{id}/returnReturn 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: true on 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, no escrow_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:

  1. 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" }
      ]
    }
    
  2. Collect against it with POST /collections, passing your own reference (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" }'
    
  3. On transaction.success, a hold is minted. Retrieve its id (escrow_hold_id) either way:

    • from the escrow.held webhook (it carries your reference), 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"
    

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:

FieldMeaning
amountTotal originally held.
released_amountCumulative released to the recipient. null while the hold is untouched; 0+ once any settlement has happened.
returned_amountCumulative returned to your available balance.
remaining_amountStill held and actionable.

status moves through:

StatusMeaning
heldFull amount still held.
partially_releasedSome released or returned; remaining_amount > 0.
releasedFully released to the recipient.
returnedFully returned to you.
disputedA dispute is open against the hold.

sub_merchant_id is an alias of recipient_id (the escrowed recipient), under the name the GET /escrow list filter uses. Filter the list by transaction_id, status, and/or sub_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" }'
FieldRequiredNotes
amountnoMinor units to release; omit for the full remaining. Must not exceed remaining_amount.
reasonno≤ 2000 chars, nullable.
referenceno≤ 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

StatusCodeWhen
422VALIDATION_ERRORBad amount, an amount over remaining_amount, or a missing Idempotency-Key.
409IDEMPOTENCY_CONFLICTSame key with a different payload, or a request still in flight.
404RESOURCE_NOT_FOUNDUnknown hold, or one owned by another merchant (its existence is never leaked).
401AUTH_INVALID_KEYMissing or invalid key / signature.

Release conditions

A hold's release_condition records how it is meant to be released:

ConditionReleases when
manualYou explicitly call POST /escrow/{id}/release (or release via the dashboard).
auto_timeoutescrow_timeout_hours elapse (surfaced as auto_release_at on the hold).
delivery_confirmationDelivery 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 (carries escrow_hold_id and your reference).
  • escrow.released — funds moved to the recipient (carries cumulative released_amount and remaining_amount).
  • escrow.returned — funds returned to your balance (carries released_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-Timestamp and X-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 event id (or widen your timestamp-acceptance window to the maximum retry horizon) rather than rejecting on age — see Webhooks → replay protection.