Wallet exchange
A person pays X F CFA from one wallet and receives X − fee on their other wallet, on the same phone number: Orange Money → Wave, or Wave → Orange Money. Same currency, no conversion: Xaalis collects on one rail and pays out on the other.
These are public endpoints (no API key), like the hosted checkout: they sit behind the Xaalis exchange page. An
exchange is protected by its own client secret cs, returned once when it is created.
The flow
Section titled “The flow”sequenceDiagram
participant P as Person (exchange page)
participant X as Xaalis API
P->>X: POST /v1/public/exchanges/quotes (from, amount, phone)
X-->>P: quote exq_… (fee, receive_amount, expires in 5 min)
P->>X: POST /v1/public/exchanges/otp (phone)
P->>X: POST /v1/public/exchanges (quote, otp) + Idempotency-Key
X-->>P: exchange exc_… + checkout_url + cs
P->>X: pays on the hosted checkout (source wallet)
X-->>P: payout on the other wallet, same phone
P->>X: GET /v1/public/exchanges/{id}?cs=… until final
1. Quote
Section titled “1. Quote”curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges/quotes \ -H "Content-Type: application/json" \ -d '{ "from": "orange_money", "send_amount": 10000, "phone": "+221771234567" }'{ "object": "exchange_quote", "id": "exq_…", "from": "orange_money", "to": "wave", "send_amount": 10000, "fee": 150, "receive_amount": 9850, "currency": "XOF", "expires_at": "…" }- Give
send_amount(what leaves the source wallet) orreceive_amount(what must arrive). For a receive amount, Xaalis finds the smallest send amount that delivers exactly that; if none does, you get422 limit_exceeded. tois always the other rail; sending the same rail on both sides is refused.- A quote lasts 5 minutes and can fund one exchange.
2. Confirm the phone
Section titled “2. Confirm the phone”curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges/otp \ -H "Content-Type: application/json" -d '{ "phone": "+221771234567" }'In test mode no SMS is sent: the response carries "test_code": "000000", and 000000 is the only accepted code.
At most 5 codes per phone per hour (429 rate_limited after that).
3. Create the exchange
Section titled “3. Create the exchange”curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges \ -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d '{ "quote": "exq_…", "otp": "000000" }'{ "object": "exchange", "id": "exc_…", "status": "awaiting_payment", "from": "orange_money", "to": "wave", "send_amount": 10000, "fee": 150, "receive_amount": 9850, "currency": "XOF", "livemode": false, "created_at": "…", "checkout_url": "https://sandbox.xaalis.org/…", "cs": "…" }Idempotency-Keyis required; retrying with the same key returns the same exchange.- Send the person to
checkout_urlto pay from the source wallet. They have 15 minutes; after that the exchange isexpiredand nothing moves. - Keep
csserver-side or in the page’s memory only — it is the only way to read the exchange.
4. Follow it
Section titled “4. Follow it”curl -s "https://sandbox-api.xaalis.org/v1/public/exchanges/exc_…?cs=$CS"Poll while the status isn’t final. The response never contains ledger, merchant or provider details, and is sent with
Cache-Control: no-store.
| Status | What it means for the person |
|---|---|
awaiting_payment |
waiting for them to pay on the checkout |
paid |
payment received; the payout is about to start |
payout_processing |
money is on its way to the other wallet |
completed |
final — receive_amount arrived on the other wallet |
expired |
final — not paid within 15 minutes; nothing was taken |
refunding |
the payout failed; the full send_amount is being returned |
refunded |
final — the full send_amount went back to the source wallet (the fee too) |
needs_review |
a Xaalis operator is checking it — the money is safe and the person will be contacted |
Only a provider-confirmed payment counts. Landing on a “success” page proves nothing.
fee = max(100, ⌈send_amount × 1.5 %⌉) F CFA, rounded up, in whole francs; receive_amount = send_amount − fee.
| Sends | Fee | Receives |
|---|---|---|
| 1 000 | 100 | 900 |
| 10 000 | 150 | 9 850 |
| 33 333 | 500 | 32 833 |
| 200 000 | 3 000 | 197 000 |
If the payout fails, the fee is returned with the rest: a refunded exchange costs the person nothing.
Limits
Section titled “Limits”| Default | |
|---|---|
| Per exchange | 1 000 – 200 000 F CFA |
| Per phone, rolling 24 h | 500 000 F CFA |
| A phone’s first 7 days | 50 000 F CFA per exchange, 100 000 F CFA per 24 h |
| Exchanges per phone per hour | 5 |
Over a limit → 422 limit_exceeded (checked at the quote, and again when the exchange is created). If the
destination wallet doesn’t have enough float for the payout → 503 liquidity_unavailable: try again later or with a
smaller amount.
Same person, both sides
Section titled “Same person, both sides”The phone that pays is the phone that receives — the payout always goes to the quote’s phone on the other rail. There is no way to send an exchange to someone else.
Testing
Section titled “Testing”Drive the pay-in without a wallet, with the exchange’s cs:
curl -s -X POST "https://sandbox-api.xaalis.org/v1/public/exchanges/exc_…/simulate?cs=$CS" \ -H "Content-Type: application/json" -d '{ "outcome": "succeeded" }'succeeded runs the whole exchange to completed; failed declines the pay-in (the exchange never pays out).
Errors
Section titled “Errors”| HTTP | code |
When |
|---|---|---|
| 400 | validation_error |
bad field, a phone that isn’t +2217XXXXXXXX, a wrong OTP |
| 403 | forbidden |
called on an API where test mode is off (production) — use sandbox-api.xaalis.org |
| 404 | not_found |
unknown exchange, or wrong cs |
| 409 | invalid_state |
the quote expired or was already used |
| 422 | limit_exceeded |
amount outside the limits above |
| 429 | rate_limited |
too many requests or OTP codes — wait Retry-After seconds |
| 503 | liquidity_unavailable |
not enough float on the destination rail right now |
The full schemas are in the API reference under Wallet exchange.