Overview

Money on Ketapay flows through a single PostgreSQL function — process_wallet_operation — which runs atomically for every credit, debit, lock, and release. No funds move outside this function. This guarantees consistency even if a webhook is delayed or retried. The full lifecycle for a standard escrow:
User tops up wallet
  └─► Escrow funded (wallet deducted + locked)
        └─► Escrow released (locked funds credited to payee's wallet)
              └─► Payee withdraws (Paystack transfer initiated)
                    └─► transfer.success webhook → payout confirmed

Funding an escrow

POST /escrow-fund accepts three payment methods. The escrow must be in draft status and the caller must be the initiator.

Wallet (instant)

Deducts from available_balance and locks funds immediately. Escrow activates synchronously — no webhook needed.
POST /escrow-fund
Authorization: Bearer <token>

{
  "escrow_id": "<id>",
  "payment_method": "wallet"
}

Saved card (instant for successful charges)

Charges the card via Paystack charge_authorization. If Paystack returns success synchronously, the escrow activates immediately. If it returns pending, activation is deferred to the charge.success webhook.
POST /escrow-fund
{
  "escrow_id": "<id>",
  "payment_method": "saved_card",
  "card_id": "<uuid>"
}

Bank transfer (async)

Returns the user’s virtual account number. The escrow stays in draft until Paystack fires charge.success for that account. The charge.success webhook then locks funds and activates the escrow automatically.
POST /escrow-fund
{
  "escrow_id": "<id>",
  "payment_method": "bank_transfer"
}
Response includes virtual_account with the account number and exact amount to transfer:
{
  "status": "pending",
  "virtual_account": {
    "account_number": "0123456789",
    "account_name": "Ketapay / Ade Johnson",
    "bank_name": "Wema Bank",
    "amount": 507625
  }
}
The user must transfer the exact amount shown, including fees. A shortfall credits their wallet but leaves the escrow in draft. They will be notified via WhatsApp of the remaining balance required.

Fee structure

Fees are calculated at escrow creation and returned as fee_breakdown on POST /escrow-create. Both parties pay a fee.
FeeRateWho pays
Platform fee1.5% of escrow amountBoth payer and payee
VAT7.5% of platform feePayer only
Example — ₦500,000 escrow:
ItemAmount
Escrow amount₦500,000
Payer platform fee (1.5%)₦7,500
VAT on fee (7.5%)₦562.50
Payer total charge₦508,062.50
Payee platform fee (1.5%)₦7,500
Payee receives₦492,500
The fee_breakdown object on the create response lets your UI show costs before the user commits to funding.

Releasing escrow funds

Full escrow release

Only the payer can release. Escrow must be in funded or inspection status.
POST /escrow-release
Authorization: Bearer <token>

{
  "escrow_id": "<id>"
}
process_wallet_operation runs with type: escrow_release — atomically unlocks the held amount and credits the payee’s available_balance. The escrow moves to released. If the payee has auto_withdraw enabled, a Paystack transfer to their default payout account fires immediately after release.

Milestone release

Only the payer releases per milestone. Milestone must be in in_review status.
POST /milestone-release
Authorization: Bearer <token>

{
  "milestone_id": "<id>"
}
The same process_wallet_operation RPC runs with p_milestone_id scoped to that milestone only. When all milestones in an escrow have been released, the escrow itself automatically moves to released.

Withdrawals

Withdrawals initiate a Paystack bank transfer. The available_balance is deducted immediately — the user cannot spend the same funds twice — and the transfer is confirmed asynchronously via webhook.
POST /wallet-withdraw
Authorization: Bearer <token>

{
  "amount": 492500
}
Destination resolution order (first match wins):
  1. one_time_payout in request body — ad-hoc bank details, optionally saved
  2. payout_account_id in request body — a specific saved account
  3. User’s default payout account

One-time payout

Send to a bank account without saving it:
POST /wallet-withdraw
{
  "amount": 492500,
  "one_time_payout": {
    "bank_code": "044",
    "bank_name": "Access Bank",
    "account_number": "0123456789",
    "account_name": "Ade Johnson",
    "save": false
  }
}
Set "save": true to add the account to saved payout accounts at the same time.

Transfer lifecycle

StepWhat happens
POST /wallet-withdraw calledBalance deducted, pending transaction created
Paystack transfer.success webhook firesTransaction marked succeeded
Paystack transfer.failed webhook firesBalance reversed, transaction marked failed
A failed transfer automatically refunds the deducted amount back to available_balance. No manual intervention is needed for the user.

Auto-withdrawal

Enable auto-withdrawal so released escrow funds are immediately sent to the default payout account without a manual withdraw step:
PATCH /wallet-toggle-auto-withdraw
Authorization: Bearer <token>

{
  "enabled": true
}
Auto-withdrawal fires after every escrow_release operation — both full escrow releases and individual milestone releases.

Paystack setup

The following Paystack features must be configured for the platform to work:
FeatureRequired for
Secret key (PAYSTACK_SECRET_KEY)All Paystack API calls
Webhook secret (PAYSTACK_WEBHOOK_SECRET)webhook-paystack signature verification
Dedicated virtual accounts (DVA)Bank transfer funding
TransfersWallet withdrawals and auto-withdrawals
Register the webhook URL in your Paystack dashboard under Settings → Webhooks:
https://<project-ref>.supabase.co/functions/v1/webhook-paystack
Enable these webhook events: charge.success, transfer.success, transfer.failed, transfer.reversed, dedicatedaccount.assign.success, dedicatedaccount.assign.failed. See the Webhooks guide for signature verification details.