Base URL: https://api.quatapay.com/api/v1
This is the single base URL for both test and live. Test vs live is chosen by the API key prefix and the path: live keys (qpay_live_…) use /gateway/payments; test keys (qpay_test_…) use /gateway/sandbox/payments. The sandbox path mirrors the live API: same request and response shape. The wrong pairing answers 403.
Send Content-Type: application/json on every request with a body. Every response uses the envelope:
{ "success": true, "data": { … }, "error": null }
Error responses:
{ "success": false, "data": null, "error": "Validation failed", "code": "VALIDATION_ERROR", "details": { "amount": ["Input should be greater than 0"] } }
Request bodies reject unknown fields: a misspelled or invented field answers 422. Every response carries an X-Request-ID header: log it, and quote it to support.
Authentication
API key (merchant gateway)
Pass your secret API key as a Bearer token, from your server only:
Authorization: Bearer qpay_live_…
User JWT (hosted checkout, customer side)
The hosted checkout page uses the signed-in customer's access token. Your integration does not need it.
Gateway payments
Create a payment intent
POST /gateway/payments (live key)
POST /gateway/sandbox/payments (test key)
Auth: API key. Header: Idempotency-Key (recommended, see below).
Body:
Response: 201 Created
{
"success": true,
"data": {
"payment": {
"id": "3f2b8c1e-5d7a-4e0b-9a61-2c4d8e7f9a10",
"slug": "Xb3kP0qLm7Rt2VwY9cZa4Hn1",
"mode": "live",
"status": "requested",
"amount": 5000,
"currency": "XAF",
"expires_at": "2026-10-28T10:00:00Z"
},
"checkout_url": "https://quatapay.com/checkout/Xb3kP0qLm7Rt2VwY9cZa4Hn1"
},
"error": null
}
The payment object also carries merchant_id, description, the URLs you sent, customer_reference, webhook_url, completed_at, completed_transaction_id, failure_reason, created_at, updated_at, settlement_currency and allowed_methods. Your metadata comes back as metadata_json.
Get a payment intent
GET /gateway/payments/{payment_id} (live key)
GET /gateway/sandbox/payments/{payment_id} (test key)
Auth: API key (must belong to the creating merchant and be the same mode). {payment_id} is the payment's id (not the slug). The response data is the payment object above.
List payment intents
GET /gateway/payments?status=succeeded&limit=50 (live key)
GET /gateway/sandbox/payments?status=succeeded&limit=50 (test key)
Auth: API key. Newest first. A test key lists only test payments, a live key only live ones.
Sandbox: finish or cancel a test payment
POST /gateway/sandbox/payments/{slug}/simulate
POST /gateway/sandbox/payments/{slug}/cancel
Auth: test API key. A test-mode checkout cannot be paid in a browser: use simulate with { "outcome": "succeeded" } or { "outcome": "failed", "failure_reason": "…" }. It sends the same gateway.payment.* webhooks as live, with "simulated": true. {slug} is data.payment.slug from creation.
Hosted checkout: preview
GET /gateway/checkout/{slug}
Auth: None (public). Returns what the checkout page shows: amount, currency, merchant name, status, expires_at, redirect URLs, order_reference, line_items, and:
The Mobile Money payer fee (currently 1.3%) is an admin setting that QuataPay can change. It is added on top of your amount, shown to the customer before they confirm, and never changes what you asked for. Read it from this response instead of hard-coding it.
Hosted checkout: pay with a wallet
POST /gateway/checkout/{slug}/pay
Auth: User JWT (the customer, used by the hosted page).
Body:
A refusal about this customer (not enough money, a frozen wallet, a limit or risk check) answers its own 4xx (for example 402) and leaves the checkout requested, so they can top up, use another account or pay by Mobile Money. Test-mode checkouts answer 400.
Hosted checkout: cancel
POST /gateway/checkout/{slug}/cancel
Auth: None.
Open a payment link on the hosted checkout
POST /payment-links/{slug}/checkout
Auth: None (public)
What the link's page (https://quatapay.com/pay/{slug}) and the "Pay with QuataPay" button (https://quatapay.com/payment-gateway/btn-pay?slug={slug}&amount=…) call. The customer then pays on the hosted checkout by QuataPay wallet or Mobile Money, with or without an account.
Response: 201 Created with {"slug", "checkout_url"}, or 200 when the same customer's open checkout of that amount is reused. The link's amount rules, expiry and single use apply: a single-use link has one open checkout at a time and is used up once that checkout is paid. 404 for an unknown link, 400 for a deactivated, used or expired one, 409 for a non-XAF link.
Payment intent statuses
There is no pending or refunded payment status.
Idempotency
Send an Idempotency-Key header (up to 255 characters) when you create a payment. Replaying the same key with the same body within 24 hours returns the first response again, with an Idempotency-Replayed: true header, instead of creating a second payment. The same key with a different body answers 409. Use one key per checkout attempt and reuse it only to retry that attempt. (The hosted-checkout pay body takes its own idempotency_key field instead of the header.)
Rate limits
Exceeding a limit returns 429 Too Many Requests with a Retry-After header (seconds). Wait that long, then retry. Need more for production? Contact support.
Errors
error is a sentence for logs. code is VALIDATION_ERROR for 422 and HTTP_<status> (for example HTTP_401) for the others. Branch on the HTTP status first.
Retry 5xx, timeouts and 429 with the same Idempotency-Key. Do not retry other 4xx responses.
Not available through the API yet
Refunds (merchants make them in the merchant panel), Orange Money, bank transfer and international cards as payment methods, and payouts to MoMo or Orange Money are not available today.