Skip to content

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.

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
Terminal window
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) or receive_amount (what must arrive). For a receive amount, Xaalis finds the smallest send amount that delivers exactly that; if none does, you get 422 limit_exceeded.
  • to is always the other rail; sending the same rail on both sides is refused.
  • A quote lasts 5 minutes and can fund one exchange.
Terminal window
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).

Terminal window
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-Key is required; retrying with the same key returns the same exchange.
  • Send the person to checkout_url to pay from the source wallet. They have 15 minutes; after that the exchange is expired and nothing moves.
  • Keep cs server-side or in the page’s memory only — it is the only way to read the exchange.
Terminal window
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.

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.

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.

Drive the pay-in without a wallet, with the exchange’s cs:

Terminal window
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).

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.