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.
| Fee | Rate | Who pays |
|---|
| Platform fee | 1.5% of escrow amount | Both payer and payee |
| VAT | 7.5% of platform fee | Payer only |
Example — ₦500,000 escrow:
| Item | Amount |
|---|
| 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):
one_time_payout in request body — ad-hoc bank details, optionally saved
payout_account_id in request body — a specific saved account
- 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
| Step | What happens |
|---|
POST /wallet-withdraw called | Balance deducted, pending transaction created |
Paystack transfer.success webhook fires | Transaction marked succeeded |
Paystack transfer.failed webhook fires | Balance 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:
| Feature | Required 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 |
| Transfers | Wallet 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.