What the API is for
FiapPay's WhatsApp bot is one front end onto an escrow engine. The /v1 API is the other: it exposes the same ledger, the same Mobile Money rails and the same mediation team to platforms that already have their own buyers, vendors and checkout: marketplaces, classifieds sites, delivery apps, ticketing platforms, freelance and service platforms.
Our own marketplace, Panier, runs on this exact contract. Third-party platforms are provisioned on the same terms, the same endpoints and the same fee logic. There is no private API behind it.
For an API escrow, your app owns the whole customer experience. Buyers and vendors receive no WhatsApp message and no Release PIN. You confirm delivery in your own product and call the release endpoint. The only thing your buyer sees from the rails is the Mobile Money prompt on their phone.
You get
- ✓Held funds, so neither side has to trust the other
- ✓MTN and Orange collection and payout, already built
- ✓Your own commission, in francs, settled monthly
- ✓Dispute mediation run by FiapPay, not by you
- ✓No buyer funds sitting on your balance sheet
Why not just run your own Mobile Money account?
A fair question, and the honest answer is arithmetic. Take a 100,000 FCFA item on a marketplace charging its vendors a 10% commission. The FiapPay figures are before the 1% Mobile Money cost of your monthly settlement transfer (9,901 and 7,426 after it); your own account's figure already includes its costs.
| Approach | You net | And you also have to… |
|---|---|---|
| Your own CamPay account collect 100,000, pay out 90,000 |
7,100 | Build collect / payout / status / retry, hold buyer money on your own balance sheet, answer the licensing question, run disputes and refunds, absorb fraud. |
| FiapPay, fee split 2.5 / 2.5 | 10,000 | Nothing. Your vendor pays 2.5%, your buyer pays 2.5%. |
| FiapPay, you absorb the vendor-side 2.5% your vendors see your 10% only |
7,500 | Nothing. |
Even when you hide FiapPay's fee from your vendors entirely, you still net more than running payments yourself, and you never build or operate a payments stack, never hold customer funds, and never mediate a dispute.
The integration, end to end
One order, from your checkout to the vendor's Mobile Money account.
Register the vendor, once
POST /v1/vendors with your own seller id, their WhatsApp number, their Mobile Money number and the account name. You get back a vendor_code and a KYC result from the network's own holder lookup. Repeat the call with the same id and you get the same vendor back, so this is safe to run on every sync.
Create the escrow at checkout
POST /v1/escrows with the order id, the buyer's phone, the vendor code, the price, and optionally your own commission in francs. FiapPay prices every leg, creates the escrow and fires the Mobile Money prompt on the buyer's phone. The response tells you exactly what the buyer will be charged, what the vendor will receive, and what a refund would return. Disclose all three in your checkout before the buyer approves.
Wait for the webhook, not for the buyer
When the buyer approves on their handset, we POST a signed escrow.funds_locked event to your endpoint. That is your signal to mark the order paid and tell the vendor to ship. Never treat the create response as payment: at that point nothing has been collected yet.
Release on delivery, or refund
When your buyer confirms delivery in your app, call POST /v1/escrows/{id}/release and the vendor is paid. If the order falls through, call POST /v1/escrows/{id}/refund and the buyer is repaid, net of the non-refundable fees and rail costs set out below. Both are atomic and idempotent: calling twice never pays twice.
Get settled monthly
Your commissions accrue to an append-only settlement ledger once the vendor payout of a released escrow succeeds, and are transferred to your Mobile Money settlement number once a month. You can ask for a statement at any time.
Build and test in the sandbox
The sandbox is a complete copy of the API at https://sandbox.api.fiappay.com: same endpoints, same responses, same signed webhooks, but no money exists in it and test numbers never prompt a phone. Get your test keys below in seconds, build your whole integration, then apply for live access.
Sandbox keys start with fiap_test_ and only work on the sandbox; live keys only work on live. Every response carries X-FiapPay-Environment: sandbox or live, so you always know which one answered. Sandbox data may be reset without notice.
1. Get your sandbox keys
Your API key and webhook secret are shown once. Copy them somewhere safe before leaving this page.
2. Use test phone numbers
Any number 2376000000NN (NN = 00–99) is a test number. Use them for buyers (payer_phone) and for vendors (whatsapp_phone, momo_number). No phone is ever prompted and no money moves.
Register vendors with the account name SANDBOX TEST ACCOUNT to get kyc_status: "verified"; any other name returns name_mismatch, so you can test both. The comparison ignores letter case and extra spaces.
3. Decide what the buyer does
A sandbox escrow waits in AwaitingPayment until you tell it what happened:
POST /v1/sandbox/escrows/{escrow_id}/simulate
{ "outcome": "success" } # or "failure", "expire"
| Outcome | Escrow becomes | Webhook |
|---|---|---|
success | FundsLocked | escrow.funds_locked |
failure | Expired | escrow.payment_failed |
expire | Expired | escrow.expired |
The simulator runs the same code as a real Mobile Money payment, so what you see is what live does. Release and refund work as normal and succeed at once.
4. Your first test, end to end
# Your sandbox key from step 1
KEY="fiap_test_..."
B=https://sandbox.api.fiappay.com
# Register a vendor (test numbers)
curl -s -X POST $B/v1/vendors -H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"external_ref":"seller-1","whatsapp_phone":"237600000050","momo_number":"237600000051","account_name":"SANDBOX TEST ACCOUNT"}'
# -> {"vendor_code":"V-XXXX","kyc_status":"verified"}
# Create an escrow for a test buyer
curl -s -X POST $B/v1/escrows -H "authorization: Bearer $KEY" -H "content-type: application/json" \
-H "Idempotency-Key: order-1-attempt-1" \
-d '{"external_ref":"order-1","payer_phone":"237600000001","payee_vendor_code":"V-XXXX","base_amount":10000,"currency":"XAF"}'
# -> "status":"AwaitingPayment", "payer_charge":10250, "payee_payout":9750
# The buyer pays
curl -s -X POST $B/v1/sandbox/escrows/ESCROW_ID/simulate -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d '{"outcome":"success"}'
# -> "status":"FundsLocked" (and escrow.funds_locked arrives at your webhook)
# Delivery confirmed in your app: release to the vendor
curl -s -X POST $B/v1/escrows/ESCROW_ID/release -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d '{}'
# -> "status":"Released" (and escrow.released arrives at your webhook)
- ▸Then try the other paths:
failureandexpire, a refund instead of a release, and a retry with the sameIdempotency-Key. - ▸Verify every webhook's signature with your sandbox
webhook_secret, exactly as in Webhooks below. - ▸Need to test on real phones with demo money? Self-serve accounts use test numbers only; ask us for a sandbox account enabled for real-phone tests.
- ▸When everything works in the sandbox, apply for live access below.
How to apply
Sandbox keys are self-service (above). Live access is granted per platform, by hand: we provision every live client from our admin dashboard, because a live key moves real money on real Mobile Money accounts. The process is short, and faster if your sandbox integration already works.
The steps
- 1Get in touch. Message our support line and tell us what your platform does, who your vendors and buyers are, and the volume you expect.
- 2Commercial review. We agree the fee tier, your commission cap, who bears the cost of a refund, and your settlement schedule. Everything is on the record before a key exists.
- 3Provisioning. We create your client record and hand you an API key and a webhook signing secret. The key is shown once: we store only its SHA-256 hash and can never recover it, only replace it.
- 4Test small, live. With your integration proven in the sandbox, run your first live escrows at the minimum amount on phones you control, end to end, including a refund.
- 5Go live. We raise your limits and you start routing real orders. Your ledger and settlement history stay visible to us, and a statement is yours on request.
Have this ready
- ▸The legal name of the company or business behind the platform
- ▸A live URL or build of the product your buyers use
- ▸An HTTPS webhook endpoint you control (plain HTTP is refused)
- ▸The Mobile Money number your monthly settlement should be paid to
- ▸Your commission model, and whether your vendors or your buyers carry it
- ▸A technical contact we can reach when a payout or webhook misbehaves
API reference
Everything below is the live /v1 contract. Field names, amounts and status values are exactly what the server sends.
Authentication
Base URLs: live https://api.fiappay.com, sandbox https://sandbox.api.fiappay.com; every path below hangs off them. Every request carries a bearer key. Content type is always JSON; the maximum request body is 1 MiB.
Authorization: Bearer <your_api_key>
Content-Type: application/json
- ▸Keys are compared in constant time against a stored SHA-256 hash. The plaintext key is never stored, never logged and cannot be recovered by anyone, including us.
- ▸A missing, unknown or suspended client's key all return the same
401. Account status is deliberately not probeable. - ▸If a client is suspended, its in-flight escrows keep settling normally and keep emitting webhooks. Money already in escrow is never stranded by an account action.
- ▸Rotating a key invalidates the old one immediately. Plan for a short deploy window when you rotate.
- ▸Keep the key server-side. It must never reach a browser, a mobile app bundle, or a repository.
- ▸Rate limits, per client: 120 requests a minute overall and 20 escrow creations a minute (each one sends a real Mobile Money prompt). Over the limit you get
429 rate_limitedwith aRetry-Afterheader in seconds; nothing was done, so retry after the delay with the sameIdempotency-Key. Failed authentication is also limited per source IP: after 30 bad or missing keys in a minute, that address gets429before its key is even checked, so fix a rejected key instead of retrying it in a loop.
Conventions and errors
Escrow status vocabulary
| status | Meaning |
|---|---|
AwaitingPayment | Created; the buyer's MoMo prompt was sent, nothing collected yet |
FundsLocked | The buyer paid; funds are held in escrow |
Released | Funds released to the vendor |
Refunded | Funds returned to the buyer |
Disputed | Under mediation (reached only via the WhatsApp dispute path) |
Expired | Never funded: the payment failed or the checkout was abandoned. No money moved |
Error shape
{ "error": { "code": "invalid_request", "message": "…human readable…" } }
Codes: unauthorized (401), invalid_request (400), not_found (404), conflict (409), idempotency_key_reused (422), rate_limited (429), upstream_error (502), internal_error (500). A request the server cannot parse gets a plain-text response instead: malformed JSON 400, missing Content-Type: application/json 415, a missing or wrongly typed field 422, a body over 1 MiB 413, and a request over 30 s 408. Branch on error.code only when the body has one.
Tenancy. Every row is scoped to the client that created it. Another platform's vendor or escrow id resolves as 404 not_found, never 403: the API does not leak the existence of records you do not own.
Money. All amounts are integer XAF: whole francs, no minor units, no decimals. currency must be the string "XAF". Never parse an amount into a float on your side either.
Vendor endpoints
/v1/vendorsRegisters a payee, or idempotently returns the one you already registered. The account name is checked against the Mobile Money holder record. That check is advisory only and never routes money by itself: a mismatch does not block onboarding, it is information for you to act on.
Request
{
"external_ref": "panier-seller-123",
"whatsapp_phone": "237670000000",
"momo_number": "237677000000",
"account_name": "Jean Dupont"
}
201 Created
{
"vendor_code": "V-8X2A",
"kyc_status": "verified"
}
- ▸
kyc_statusisverified(holder name matches),name_mismatch(it differs; worth a look before you let them sell), orunverified(the lookup was unavailable). - ▸Idempotent on your
external_ref: repeating it with the same phones returns the existing vendor with200 OK, as the full vendor object shown underGETbelow. - ▸All four fields are required, at most 128 characters each. Phone numbers are digits only with an optional leading
+, 8 to 15 digits; a pre-2016 8-digit Cameroon number (without the leading 6) gets400naming the corrected number.external_refmust not have leading or trailing spaces. - ▸A WhatsApp number belongs to the first platform that registers it. Another platform registering the same number gets
409 conflict. Reusing one of your ownexternal_refs for a different phone also gets409, and so does registering a known WhatsApp number with a different payout number: payout numbers can only be changed by FiapPay.
/v1/vendors/{vendor_code}Reads back one of your vendors. Someone else's code, or an unknown one, returns 404. account_name is the name on the Mobile Money account when the holder lookup succeeded (even on name_mismatch), and the name you sent only when it was unverified.
{
"vendor_code": "V-8X2A",
"external_ref": "panier-seller-123",
"whatsapp_phone": "237670000000",
"momo_number": "237677000000",
"account_name": "JEAN DUPONT",
"kyc_status": "verified"
}
Escrow endpoints
/v1/escrowsPrices every leg, creates the escrow, and fires the buyer's Mobile Money prompt for the payer charge. Send an Idempotency-Key header (1–255 printable ASCII characters, no spaces, not starting with ext:, else 400). Retries are already deduplicated on external_ref; the key also returns your result without spending create budget, and rejects reuse with different parameters.
Request
Idempotency-Key: 9f1c…-opaque
{
"external_ref": "panier-order-9981",
"payer_phone": "237600000001",
"payee_vendor_code": "V-8X2A",
"base_amount": 100000,
"currency": "XAF",
"payer_commission": 0,
"payee_commission": 10000,
"metadata": { "order_id": "9981" }
}
201 Created
{
"escrow_id": "5f2b…-uuid",
"external_ref": "panier-order-9981",
"status": "AwaitingPayment",
"base_amount": 100000,
"payer_charge": 102500,
"payee_payout": 87500,
"fee_total": 5000,
"fee_bps": 500,
"payer_commission": 0,
"payee_commission": 10000,
"refund_amount": 96000,
"payout_status": null,
"currency": "XAF",
"created_at": "…",
"updated_at": "…"
}
The identity that always holds: payee_payout + fee_total + payer_commission + payee_commission == payer_charge, exactly, on every snapshot. Integer francs, nothing stranded. Read the legs from the response and never hardcode FiapPay's rate in your own code.
Fields
| Field | Meaning |
|---|---|
base_amount | The vendor's price. FiapPay's fee is a percentage of this, never of your commission. |
payer_commission | Optional, default 0. Your commission charged to the buyer, in francs, added on top of the base and FiapPay's fee in the MoMo prompt. |
payee_commission | Optional, default 0. Your commission held back from the vendor's payout, in francs. |
refund_amount | What the buyer would receive if this escrow were refunded, under the terms snapshotted on it. Returned at creation so you can disclose it before the buyer approves the prompt. After a refund it is the amount actually sent. |
metadata | Optional. Accepted and ignored: it is not stored, not returned, and not part of the idempotency comparison. |
Your commission model is yours
7%, 10%, 15%, vendors only, buyers only, both sides, a flat 500 FCFA, free for a seller's first ten sales: whatever your model is, you do that arithmetic on your side and send us the answer in francs. We never learn what percentage it was, and FiapPay charges no fee on it (only the Mobile Money costs of moving it are passed through, see Settlement). That is why any marketplace's pricing fits without a contract change.
Validation
- ▸
currencymust be"XAF". - ▸
base_amountmust sit inside the configured range (defaults: 100 to 1,000,000 FCFA). - ▸
payee_vendor_codemust be one of your vendors, else400. A vendor FiapPay has suspended gets409(its existing escrows still settle). - ▸
external_ref,payer_phone,payee_vendor_code,base_amountandcurrencyare required; strings at most 128 characters,external_refwithout leading or trailing spaces.payer_phonefollows the vendor phone rules above. - ▸Each commission must sit within your commission cap (30% of
base_amountby default, set per client, hard ceiling 50%), else400 invalid_requestnaming the cap. The cap is a safety bound on how much of a vendor's payout one request can redirect. It is not a rule about what you may charge. - ▸A
payee_commissionthat would leave the vendor with nothing is refused with400.
/v1/escrows/{escrow_id}Returns the same snapshot shape, reflecting live status. Note that payee_payout is the vendor's payout net of your payee_commission, not the base price. Use this for reconciliation, not as a substitute for webhooks.
/v1/escrows/{escrow_id}/releaseBody: empty {}. This call is server-authoritative: by making it, your platform asserts that the buyer confirmed delivery in your app. It replaces the WhatsApp Release PIN for API escrows, so guard it in your own product the way you would guard a payout button.
- ▸Atomically moves
FundsLocked → Releasedunder a double-spend guard and disbursespayee_payout. - ▸Idempotent: releasing an already-released escrow returns
200and does not pay twice. - ▸
AwaitingPayment(not funded),Refunded,ExpiredorDisputed→409. A vendor with no Mobile Money number on file also gets409. - ▸If the immediate payout is rejected by the network, the escrow is still
Releasedand the payout is retried by our reconcile worker. Theescrow.releasedwebhook fires when the money actually lands, not when you called.payout_statusshows where it is:nullbefore release, thenpending→sent, orfailed(retried automatically) orambiguous(outcome unknown, reviewed by an operator before any retry). - ▸Your commissions accrue to your settlement balance once the vendor payout succeeds.
/v1/escrows/{escrow_id}/refundBody: empty {}. Atomically moves FundsLocked → Refunded under the same double-spend guard and returns refund_amount to the buyer's Mobile Money, priced under the refund terms snapshotted on the escrow.
- ▸Idempotent: refunding an already-refunded escrow returns
200and does not refund twice. - ▸
AwaitingPaymentorExpired→409(nothing was collected, so there is nothing to refund).ReleasedorDisputed→409. - ▸Your
payee_commissionnever accrues on a refund. Yourpayer_commissionis either returned to the buyer or kept for you, according to your client terms. - ⚠Limitation: a failed refund transfer is not auto-retried. Our reconcile worker only retries vendor payouts. The escrow is marked
Refundedand an operator alert is raised so a human completes the transfer. Noescrow.refundedwebhook is sent in that case: the200response is your signal. If a buyer tells you a refund has not arrived, contact us rather than calling the endpoint again.
Idempotency: how not to charge twice
Networks retry, timeouts lie, and a user will tap Pay twice. This API is built on the assumption that duplicates will arrive.
- ▸Same
Idempotency-Key(per client) with the same parameters →200with the escrow's current snapshot (it may have been funded since). A replay never spends your create budget. The same key with different parameters (reference, phone, vendor, amounts or currency) →422 idempotency_key_reused, and nothing is created. - ▸Same
external_ref(per client) with the same parameters → the existing escrow is returned with200. With different parameters →409 conflict: a new order needs a newexternal_ref. - ▸The buyer's Mobile Money prompt is fired at most once per
external_ref. - ▸A concurrent in-flight duplicate returns
409 conflict. Retry once the first has settled. - ▸If CamPay definitely rejects the prompt, the escrow is rolled back (nothing is left half-created) and you get
502 upstream_error; you may safely retry the sameexternal_ref. If the call to CamPay times out, the prompt may have reached the buyer, so the escrow is kept and returned201 AwaitingPayment: a retry can never prompt twice. It locks if the payment arrives and expires after 48 h if not.
Recommended: derive external_ref from your own order id and keep it stable forever. Derive the Idempotency-Key per attempt. Then a retry is always free and a genuinely new order can never collide with an old one.
Webhooks
When an API-created escrow changes state, we POST a signed event to your webhook_url, which must be https://. Delivery goes through a durable outbox and a retrying dispatcher, so it is at-least-once: you must dedupe on the event id.
| type | Fired when |
|---|---|
escrow.funds_locked | The buyer paid; funds are locked in escrow. This is your "order paid" signal. |
escrow.released | Funds actually landed with the vendor |
escrow.refunded | Funds returned to the buyer |
escrow.disputed | The escrow entered dispute (WhatsApp mediation path only) |
escrow.payment_failed | The buyer's payment failed (declined, wrong PIN, insufficient balance, prompt timed out). Status Expired |
escrow.expired | Still unpaid 48 hours after creation: an abandoned checkout. Status Expired |
For API escrows, escrow.funds_locked replaces the WhatsApp notifications: the buyer and vendor get no message and no Release PIN. In a normal API flow you cancel with POST …/refund rather than through a dispute.
Likewise, escrow.payment_failed replaces the WhatsApp "payment failed" message: tell the buyer yourself, and create a new escrow with a new external_ref if they want to try again. Expired is terminal with one rare exception: if the buyer's money arrives late anyway, the escrow is revived and you receive escrow.funds_locked. We never keep a payment we cannot hold in escrow; if you had cancelled the order, release or refund it as usual.
Headers
Content-Type: application/json
X-FiapPay-Timestamp: <unix seconds>
X-FiapPay-Signature: sha256=<hex hmac>
X-FiapPay-Event-Id: <uuid>
X-FiapPay-Event-Type: escrow.released
The event id is stable across retries: it is your dedupe key.
Payload
{
"id": "e2c1…-uuid",
"type": "escrow.released",
"created_at": "2026-09-03T05:31:00.482913507+00:00",
"data": {
"escrow_id": "5f2b…-uuid",
"external_ref": "panier-order-9981",
"status": "Released",
"base_amount": 100000,
"payer_charge": 102500,
"payee_payout": 87500,
"fee_total": 5000,
"fee_bps": 500,
"payer_commission": 0,
"payee_commission": 10000,
"refund_amount": 96000,
"currency": "XAF"
}
}
Verifying a delivery
ts = headers["X-FiapPay-Timestamp"]
if not is_integer(ts) or abs(now_unix() - int(ts)) > 300:
return 401 # stale or replayed
# over the timestamp + "." + the RAW request bytes, exactly as received
expected = "sha256=" + hex(hmac_sha256(webhook_secret, ts + "." + raw_request_body))
if not constant_time_equals(expected, headers["X-FiapPay-Signature"]):
return 401
if seen(headers["X-FiapPay-Event-Id"]):
return 200 # already handled, idempotent
process(body); mark_seen(id); return 200
- ▸Sign and verify over the raw bytes, before any JSON parsing or re-serialisation. Re-encoding the body will break the signature.
- ▸Compare in constant time. A plain string equality here is a timing oracle.
- ▸The timestamp is inside the signature, so a captured delivery cannot be replayed later or re-dated. Each retry is signed afresh with its own send time. Reject anything more than 5 minutes off your clock, and keep that clock NTP-synced.
- ▸Answer
2xxto acknowledge. Any non-2xx or timeout triggers retry with exponential backoff (base 30s, capped at 1h, up to 12 attempts) before the delivery is marked failed. - ▸A missing signing key never causes an event to be sent unsigned or dropped: deliveries wait in the outbox without using up retry attempts. An event's
created_atis RFC 3339 with fractional seconds (3, 6 or 9 digits); an escrow'screated_atandupdated_atare UTCYYYY-MM-DD HH:MM:SS. - ▸Events can arrive out of order (each retries on its own schedule), and a payload is a snapshot taken when the event was queued. When an event contradicts what you hold, treat
GET /v1/escrows/{id}as the source of truth. Acknowledge within 10 s (our timeout) and do slow work after replying.
Fees and your commissions
The list price is 5% of base_amount, split 2.5% onto the buyer and 2.5% off the vendor (fee_bps: 500). Volume tiers are agreed per platform and reported as fee_bps on every snapshot. The rate is snapshotted at creation, so a later tier change never re-prices an escrow that is already in flight. It is computed server-side and is never client-supplied.
| base_amount | payer_charge | payee_payout | fee_total |
|---|---|---|---|
| 20,000 | 20,500 | 19,500 | 1,000 |
| 50,000 | 51,250 | 48,750 | 2,500 |
| 100,000 | 102,500 | 97,500 | 5,000 |
Your commission, stacked on top
The same 100,000 FCFA item under four different marketplace models. Every row balances exactly.
| Your model | payer_charge | payee_payout | fee_total | You accrue |
|---|---|---|---|---|
| No commission | 102,500 | 97,500 | 5,000 | 0 |
| 10%, vendors only | 102,500 | 87,500 | 5,000 | 10,000 |
| 10%, buyers only | 112,500 | 97,500 | 5,000 | 10,000 |
| 5% + 5%, both sides | 107,500 | 92,500 | 5,000 | 10,000 |
Read the second row carefully: with a vendors-only commission, your buyer's experience is identical to having no marketplace commission at all: they still pay 102,500. Your 10,000 comes out of the vendor's side.
Refunds are net of non-refundable fees and rail costs
FiapPay previously absorbed the cost of a refund and returned the buyer's full charge. It no longer does. Fees are never refundable, and FiapPay does not absorb the Mobile Money costs a refunded transaction has already incurred.
The core refund is base_amount minus three deductions (collection cost, refund-sending cost, handling fee), each a fixed rate on the base, rounded down in integer francs. FiapPay's buyer-side fee, which the buyer paid on top of the base, is not returned either. refund + kept == payer_charge is asserted exactly on every refund.
| Deduction | Rate |
|---|---|
FiapPay buyer-side fee, paid on top of the base, not returned (half of fee_bps) | 2.5% at list |
| Mobile Money collection cost, already incurred on deposit | 2.0% |
| Mobile Money cost of sending the refund | 1.0% |
| FiapPay refund-handling fee | 1.0% |
| base | payer_charge | refund_amount |
|---|---|---|
| 20,000 | 20,500 | 19,200 |
| 50,000 | 51,250 | 48,000 |
| 100,000 | 102,500 | 96,000 |
Figures shown with no marketplace commission. Read refund_amount from the escrow rather than recomputing it.
Your obligation as an integrator: refund_amount is returned on the create response precisely so you can show it to the buyer before they approve the Mobile Money prompt. A buyer who is refunded 96,000 after paying 102,500 must have been told that up front, in your checkout. Do not describe a FiapPay escrow as offering a "full refund" or "money-back guarantee".
Two terms we agree with you
Set per platform and snapshotted on every escrow. Example below: 100,000 base, buyer paid 112,500 including your 10,000 buyer-side commission.
| Deductions borne by | Your buyer-side commission | Buyer receives | FiapPay keeps | You accrue | Debited from your balance |
|---|---|---|---|---|---|
| the buyer (default) | returned (default) | 106,000 | 6,500 | 0 | 300 |
| the buyer | kept | 96,000 | 6,500 | 10,000 | 200 |
| your marketplace | returned | 112,500 | 0 | 0 | 6,800 |
| your marketplace | kept | 102,500 | 0 | 10,000 | 6,700 |
In every row, buyer + FiapPay + you == what the buyer paid. Choosing to bear the deductions yourself is how a platform offers its customers a genuinely whole refund: the cost moves onto your settlement balance rather than disappearing.
A dispute split, on the WhatsApp mediation path, gives the buyer a percentage of the core refund and the vendor a percentage of payee_payout, computed on the same basis. Your payee_commission follows the vendor's share; your payer_commission follows the rule above. The "deductions borne by" setting does not apply to splits: the buyer's share always comes from the net core refund.
How your commissions are settled
Your commissions are carved out of money FiapPay already holds, so they never touch the escrow flow and never delay a vendor. They are earned when an escrow is released and credited to your settlement balance once the vendor payout succeeds. On a refund your payee_commission is never earned; your payer_commission is credited only if your terms keep it.
Because FiapPay never absorbs rail costs, your balance carries the rail costs attributable to your own commissions:
| Debit | When | Rate |
|---|---|---|
Collection cost on your payer_commission | at release | 2% |
Returning your payer_commission to a refunded buyer | at refund, if returned | 3% |
Collection cost on a payer_commission you keep | at refund, if kept | 2% |
| FiapPay's refund deductions, when your platform bears them | at refund | the deductions |
| Payout cost on each settlement transfer | at payout | 1% |
Settlement is monthly: we transfer the largest amount your balance covers after the 1% payout cost (for example 10,100 → 10,000 sent + 100 cost) to your settlement Mobile Money number and record it. Refund debits can take your balance below zero; nothing is paid out until later commissions bring it back above zero. The ledger is append-only, and a statement is available on request at any time.
Limits and things we would rather you heard from us
- ⚠Refunds are not auto-retried. Vendor payouts are; refunds are not. A failed refund raises an operator alert and is completed by hand.
- ⚠Vendor phone ownership is global. The first platform to register a WhatsApp number owns it; another platform registering the same number gets
409. If you and another FiapPay platform share a seller, talk to us. - ⚠Unfunded escrows cannot be refunded. Nothing was collected, so there is nothing to return. Let an unpaid escrow expire instead.
- ⚠Payment failures come to you, not the buyer. On API escrows the buyer gets no WhatsApp message or retry link; you receive
escrow.payment_failedand handle the retry in your own app. - ⚠Throughput is finite and we will tell you the number. Escrow creation is serialised. Before you point sustained high-volume traffic at us, agree a rate with us so we can provision for it. We would rather size the platform for your launch than discover it during one.
- ⚠Escrows are XAF and Cameroon Mobile Money only at v1: MTN and Orange, via CamPay. Other markets are on the roadmap, not in this contract.
Go-live checklist
Work through this before you route a real customer's money.
- ☐The API key lives only in server-side configuration, never in a client bundle, never in git.
- ☐Webhook signatures are verified over raw bytes, in constant time, and unsigned or badly-signed deliveries are rejected.
- ☐Events are deduped on
X-FiapPay-Event-Id; replaying the same event twice does not ship an order twice. - ☐You treat
escrow.funds_locked, not the create response, as proof of payment. - ☐Every create carries a stable
external_refand a per-attemptIdempotency-Key. - ☐Amounts are handled as integers end to end. No floats, no rounding, anywhere in your stack.
- ☐Your checkout shows the buyer the charge, the vendor payout and
refund_amountbefore they approve. - ☐The release call is behind real authorisation in your app: it is the API's payout button.
- ☐You have run at least one real end-to-end escrow, including a refund, at the minimum amount, on phones you control.
- ☐Your logs record escrow ids and order ids, never phone numbers, account names or full request bodies.
Put escrow in your checkout
Tell us what you are building and we will walk you through the fee tier, the refund terms and the keys. Most integrations are running their first test escrow within a day.
Request API accessAlready integrated? The same line reaches us for incidents, key rotation and statements.