Skip to content

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

Terminal
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"
  }'
FieldRequiredDescription
amountyesAmount in XOF: digit string, at least the rail's min_amount ("5000", not 5000).
currencyyesAlways "XOF".
railyesRail identifier: sn_wave, ci_orange… See Rails and countries.
merchant_referenceyesOrder reference: 1 to 128 characters among letters, digits and . _ : / # -. Unique per project.
return_urlyeshttps:// URL the customer returns to after paying. An http:// URL, including http://localhost, is refused (400 INVALID_REQUEST, field return_url).
customer_phonelive modeCustomer 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_otpci_orangeCustomer one-time code, 4 to 8 digits. See Rails and countries, section "OTP code".
customer_devicenosn_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_codenoProject 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.

201 · application/json
{
  "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.

typeUseful fieldHandling
redirecturlRedirect the customer to url.
qrqr_codeDisplay 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.
instructionmessageDisplay message as is: the customer follows the instruction on their phone.

The outcome then arrives by webhook or by reading the payment again.

Statuses

StatusMeaningWebhook event
createdPayment recorded.None
pendingRequest sent, customer payment awaited.None
confirmedFunds received, fee and net set, net amount credited to the available balance.payment.succeeded
failedPayment declined or abandoned. error_code: provider_failed.payment.failed
expiredPayment window elapsed. error_code: expired.payment.expired
unknownUncertain 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

Terminal
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:

ParameterDescription
statusOne or several statuses, repeated or comma-separated.
railRail.
qPayment id, or merchant_reference exact or by prefix.
customer_phoneCustomer number.
from, tocreated_at range (RFC 3339): from inclusive, to exclusive.
limit1 to 100, default 20.
cursornext_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.

SituationHandling
No response to POST /v1/paymentsReplay the same request with the same Idempotency-Key.
202 with only idRead GET /v1/payments/{id} until a final status.
Status unknownKeep the order pending, with no second payment. The webhook or a read gives the outcome as soon as the operator answers.
503 CHANNEL_UNAVAILABLEOffer another rail or retry later.

An unknown payment credits the balance only once confirmed.

Common errors

StatuscodeCause
400INVALID_REQUESTInvalid fields, one message per field in fields.
403MERCHANT_SUSPENDEDMerchant account suspended.
403MERCHANT_LIMIT_EXCEEDEDDaily or monthly collection limit reached.
409IDEMPOTENCY_CONFLICTKey already used with another body.
409DUPLICATE_MERCHANT_REFERENCEmerchant_reference already used in the project.
503CHANNEL_UNAVAILABLERail not activated on the project, or temporarily closed.

Full table: Errors.