Payments
Create a payment, follow its status, replay a request without duplicates and handle an unknown outcome.
A payment is a collection request sent to a customer on a given rail. It is
recorded as created, becomes pending as soon as the operator has answered
(the creation response already carries pending), then ends confirmed,
failed or expired.
Create a payment
curl -X POST "$API_URL/v1/payments" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: order-1042" \
-H "Content-Type: application/json" \
-d '{
"amount": "5000",
"currency": "XOF",
"rail": "sn_wave",
"merchant_reference": "order-1042",
"return_url": "https://shop.example/payment/return",
"customer_phone": "77 123 45 67"
}'| Field | Required | Description |
|---|---|---|
amount | yes | Amount in XOF: digit string, at least the rail's min_amount ("5000", not 5000). |
currency | yes | Always "XOF". |
rail | yes | Rail identifier: sn_wave, ci_orange… See Rails and countries. |
merchant_reference | yes | Order reference: 1 to 128 characters among letters, digits and . _ : / # -. Unique per project. |
return_url | yes | https:// URL the customer returns to after paying. An http:// URL, including http://localhost, is refused (400 INVALID_REQUEST, field return_url). |
customer_phone | live mode | Customer mobile number, valid in the rail's country. Local format (77 123 45 67) or E.164 (+221771234567); returned in E.164. Also used for refunds. |
customer_otp | ci_orange | Customer one-time code, 4 to 8 digits. See Rails and countries, section "OTP code". |
customer_device | no | sn_orange: the customer's device, mobile (default) or desktop. mobile opens the Max it app directly; desktop returns a QR code (next_action.type = qr, valid 5 minutes) that the customer scans with their phone. customer_phone stays required in live mode, with desktop too. 221 Pay payment pages choose by device on their own. |
offer_code | no | Project promo code. amount is then reduced by the discount; the response adds offer_code, discount_amount and original_amount. |
A field absent from this table is refused: 400 INVALID_REQUEST, with
fields.<field name>. Response fields, such as payment_link_description,
are not sent.
Response
201 on creation, 200 for a request replayed with the same
Idempotency-Key and the same body.
{
"id": "8d1f0c6e-3b7a-4c55-9a51-2f0e5c1d7a90",
"status": "pending",
"amount": "5000",
"currency": "XOF",
"rail": "sn_wave",
"merchant_reference": "order-1042",
"checkout_url": "https://…",
"next_action": { "type": "redirect", "url": "https://…" },
"created_at": "2026-10-07T10:00:00Z",
"withdrawal_eligibility": "not_confirmed",
"fee": null,
"net": null,
"livemode": false
}Redirect the customer to checkout_url. On Wave, next_action is a redirect
(type redirect) to the same page. livemode is false with a test key and
true with a live key. fee and
net (amount credited to the merchant) are set on confirmation.
A payment made on a payment link also carries
payment_link_description, the link's description, to display in place of
merchant_reference (of the form link:<link>:<payment>). The field is absent
from other payments.
Customer action: next_action
Depending on the operator, the response carries next_action, the step the
customer completes. checkout_url is then null when the operator provides no
page.
type | Useful field | Handling |
|---|---|---|
redirect | url | Redirect the customer to url. |
qr | qr_code | Display the qr_code image (PNG as a data: URI, usable as is as an image source). The customer scans it with their mobile money app. |
instruction | message | Display message as is: the customer follows the instruction on their phone. |
The outcome then arrives by webhook or by reading the payment again.
Statuses
| Status | Meaning | Webhook event |
|---|---|---|
created | Payment recorded. | None |
pending | Request sent, customer payment awaited. | None |
confirmed | Funds received, fee and net set, net amount credited to the available balance. | payment.succeeded |
failed | Payment declined or abandoned. error_code: provider_failed. | payment.failed |
expired | Payment window elapsed. error_code: expired. | payment.expired |
unknown | Uncertain outcome after an incident. See "Unknown outcome". | None |
confirmed is final. withdrawal_eligibility then becomes confirmed: the
net amount can be withdrawn.
Read and list
curl "$API_URL/v1/payments/$PAYMENT_ID" -H "Authorization: Bearer $API_KEY"
curl "$API_URL/v1/payments?status=confirmed,failed&limit=20" -H "Authorization: Bearer $API_KEY"GET /v1/payments/{id} adds error_code, error_message (in the
Accept-Language language), customer.phone and refunds.
GET /v1/payments filters:
| Parameter | Description |
|---|---|
status | One or several statuses, repeated or comma-separated. |
rail | Rail. |
q | Payment id, or merchant_reference exact or by prefix. |
customer_phone | Customer number. |
from, to | created_at range (RFC 3339): from inclusive, to exclusive. |
limit | 1 to 100, default 20. |
cursor | next_cursor of the previous page; null on the last page. |
The response contains data, next_cursor and total_count.
Replay without duplicates: Idempotency-Key
The Idempotency-Key header is required on POST /v1/payments (1 to 128
characters). A key stands for one logical attempt, such as an order.
- Same key, same body: the same payment is returned (
200), never a second one. - Same key, different body:
409 IDEMPOTENCY_CONFLICT, nothing is created. - A key only applies to the route it was used on.
After a timeout or a network cut, send exactly the same request with the same
key. merchant_reference is unique per project: a reference already used
with another key returns 409 DUPLICATE_MERCHANT_REFERENCE.
Unknown outcome
An incident can leave a payment outcome uncertain. 221 Pay never re-requests a payment on its own: a double charge is ruled out.
| Situation | Handling |
|---|---|
No response to POST /v1/payments | Replay the same request with the same Idempotency-Key. |
202 with only id | Read GET /v1/payments/{id} until a final status. |
Status unknown | Keep the order pending, with no second payment. The webhook or a read gives the outcome as soon as the operator answers. |
503 CHANNEL_UNAVAILABLE | Offer another rail or retry later. |
An unknown payment credits the balance only once confirmed.
Common errors
| Status | code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | Invalid fields, one message per field in fields. |
| 403 | MERCHANT_SUSPENDED | Merchant account suspended. |
| 403 | MERCHANT_LIMIT_EXCEEDED | Daily or monthly collection limit reached. |
| 409 | IDEMPOTENCY_CONFLICT | Key already used with another body. |
| 409 | DUPLICATE_MERCHANT_REFERENCE | merchant_reference already used in the project. |
| 503 | CHANNEL_UNAVAILABLE | Rail not activated on the project, or temporarily closed. |
Full table: Errors.