Collections
A collection pulls money in from a payer: a mobile money or bank debit that credits your merchant balance. You create a collection intent, we route it to a provider, and the transaction moves through its lifecycle with a webhook at every step. Amounts are decimal strings with an explicit currency; zero-decimal currencies such as XAF and XOF carry no fraction.
The collection object
Every collections endpoint returns the same object inside the response envelope. The id is prefixed col_, amounts are decimal strings in the currency's own units, and fees are always broken out. These are the fields you interpret most often:
- statusstringrequired
- Where the collection is in its lifecycle. Always branch on this; never assume success.
- amountstringrequired
- The requested amount, as a decimal string in the collection's currency.
- feesobjectrequired
- The fee breakdown: platform_fee, provider_fee, total_fee (all decimal strings) and bearer (merchant or customer).
- net_amountstring | nullrequired
- The amount credited to your balance after fees, as a decimal string. Null until the collection is captured.
- order_amountstring | nullrequired
- Customer-pays collections only: the order you asked for. amount is what the payer was charged and net_amount equals this exactly. Null when your business pays the fee.
- auth_modelstring | nullrequired
- Which continuation the payer needs: STK_PROMPT, OTP, REDIRECT or NONE. Tells your client what to show next.
- hosted_urlstring | nullrequired
- When auth_model is REDIRECT, the provider's hosted payment page. Send the payer's browser there to complete the payment; null on prompt-based (STK_PROMPT) collections.
- reason_codestring | nullrequired
- A machine-readable reason from the closed reason-code vocabulary, set on non-success outcomes. Null while the collection is healthy.
- requires_requerybooleanrequired
- True when dispatch timed out and the final status will arrive from the reconciler by webhook rather than in this response.
- operatorstring | nullrequired
- The specific operator the transaction was routed to (for example MTN), when known.
- livemodebooleanrequired
- False for sandbox collections, true for production. Mirrors the environment of the key that created it.
Create a collection
Creates the collection intent and dispatches it. This call moves money, so it requires the collections:write scope and an Idempotency-Key: retrying with the same key and body replays the stored response instead of charging the payer twice. Card is not served on this surface (no card data ever enters the API); use mobile_money or bank. On success you get 201 with the collection in its post-dispatch state (pending for a mobile money prompt).
POST/v1/collections
Headers
- Authorizationheaderrequired
- Your secret key as a bearer token: Bearer sk_test_... in sandbox, Bearer sk_live_... in production.
- Idempotency-Keyheaderrequired
- A unique key (1-255 chars) that makes the call safe to retry. Same key and body replays the stored response; same key with a different body returns idempotency_key_reused.
- X-Scenario-Keyheadersandbox only
- Forces a named simulator scenario (for example success.late or failed.insufficient_funds). Ignored in production; an unknown key returns validation_error.
- X-Correlation-Idheaderoptional
- An optional client-supplied id, echoed back on the response and stored in your request logs.
Request body
- amountstringrequired
- The amount to collect, as a decimal string in the currency's major unit. Zero-decimal currencies (XAF, XOF, UGX, RWF and others) take a whole number such as "5000"; two-decimal currencies take up to two places such as "12.50". Must be greater than zero.
- currencystringrequired
- The 3-letter ISO 4217 currency code, uppercase (for example XAF).
- countrystringrequired
- The 2-letter ISO 3166-1 country code, uppercase (for example CM).
- methodstringrequired
- How the payer pays: mobile_money or bank. card is rejected with method_not_available; card data is never accepted by this API.
- msisdnstringrequired
- The payer's phone number, 8 to 15 digits with an optional leading +. Required when method is mobile_money.
- merchant_referencestringrequired
- Your own reference for this collection, up to 128 characters, unique per app. Reusing a value returns validation_error, which makes it a second line of defense against duplicates.
- customerobjectoptional
- Free-form details about the payer that you attach and we echo back unchanged.
- metadataobjectoptional
- Free-form key/value pairs you attach and we echo back unchanged.
- fee_bearerstringoptional
- Who pays the collection fee: merchant or customer. Omit it to use your business setting (default merchant). With customer, amount is your order and the payer is charged the order plus the fee.
Request
curl https://api.capayglobal.com/v1/collections \
-H "Authorization: Bearer sk_test_8fK2q0Zr41Xa" \
-H "Idempotency-Key: col-order-10482-a1b2c3" \
-H "Content-Type: application/json" \
-d '{
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"merchant_reference": "order-10482",
"customer": { "name": "Ada Nkemelu", "email": "[email protected]" },
"metadata": { "cart_id": "cart_88213" }
}'Response
{
"status": "success",
"data": {
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "pending",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"merchant_reference": "order-10482",
"customer": { "name": "Ada Nkemelu", "email": "[email protected]" },
"metadata": { "cart_id": "cart_88213" },
"fees": {
"platform_fee": "100",
"provider_fee": "0",
"total_fee": "100",
"bearer": "merchant"
},
"net_amount": null,
"auth_model": "STK_PROMPT",
"hosted_url": null,
"reason_code": null,
"requires_requery": false,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-09-01T10:15:00Z",
"completed_at": null,
"expires_at": "2026-09-01T10:45:00Z"
},
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}Retrieve a collection
Fetches one collection by its id (col_...) or raw UUID. Always scoped to your merchant and the key's environment: an id from the other environment returns 404. Needs the collections:read scope.
GET/v1/collections/{id}
curl https://api.capayglobal.com/v1/collections/col_7Qm3k1Xa9Zr4 \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
{
"status": "success",
"data": {
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "completed",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"merchant_reference": "order-10482",
"customer": { "name": "Ada Nkemelu", "email": "[email protected]" },
"metadata": { "cart_id": "cart_88213" },
"fees": {
"platform_fee": "100",
"provider_fee": "0",
"total_fee": "100",
"bearer": "merchant"
},
"net_amount": "4900",
"auth_model": "STK_PROMPT",
"hosted_url": null,
"reason_code": null,
"requires_requery": false,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-09-01T10:15:00Z",
"completed_at": "2026-09-01T10:15:08Z",
"expires_at": "2026-09-01T10:45:00Z"
},
"request_id": "5c7d9e10-2b34-4a56-9f78-e01a2b3c4d5e"
}List collections
Returns your collections newest first, as a keyset-paginated list. Needs the collections:read scope. has_more tells you whether another page exists; pass the last id as starting_after to fetch it.
GET/v1/collections
Query parameters
- limitintegeroptional
- How many collections to return, 1 to 100. Defaults to 10.
- statusstringoptional
- Return only collections in this status (for example completed or pending).
- starting_afterstringoptional
- A collection id (col_...) to page after. Use the id of the last row from the previous page.
curl "https://api.capayglobal.com/v1/collections?limit=1&status=completed" \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
{
"status": "success",
"data": {
"object": "list",
"data": [
{
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "completed",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"msisdn": "+237650000001",
"merchant_reference": "order-10482",
"fees": { "platform_fee": "100", "provider_fee": "0", "total_fee": "100", "bearer": "merchant" },
"net_amount": "4900",
"auth_model": "STK_PROMPT",
"hosted_url": null,
"reason_code": null,
"requires_requery": false,
"provider": "simulator",
"operator": "MTN",
"livemode": false,
"created_at": "2026-09-01T10:15:00Z",
"completed_at": "2026-09-01T10:15:08Z",
"expires_at": "2026-09-01T10:45:00Z"
}
],
"has_more": true
},
"request_id": "9b2c1a08-3e44-4f6d-8a17-c05e9f21d3b6"
}Cancel a collection
Cancels a collection that has not yet reached a provider outcome. Only an initiated or pending collection can be cancelled; anything else returns validation_error. This call takes no Idempotency-Key. Needs the collections:write scope.
POST/v1/collections/{id}/cancel
curl -X POST https://api.capayglobal.com/v1/collections/col_7Qm3k1Xa9Zr4/cancel \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
{
"status": "success",
"data": {
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "cancelled",
"amount": "5000",
"currency": "XAF",
"reason_code": null,
"requires_requery": false,
"livemode": false,
"created_at": "2026-09-01T10:15:00Z",
"completed_at": null,
"expires_at": "2026-09-01T10:45:00Z"
},
"request_id": "c4a1f8e2-9d30-47b6-8e5f-1a2b3c4d5e6f"
}Collection events
Returns the append-only status history for one collection, oldest first: every transition, the actor that made it, and the payload recorded with it. This is the audit trail behind the object's current status. Needs the collections:read scope.
GET/v1/collections/{id}/events
curl https://api.capayglobal.com/v1/collections/col_7Qm3k1Xa9Zr4/events \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
{
"status": "success",
"data": {
"object": "list",
"collection": "col_7Qm3k1Xa9Zr4",
"data": [
{
"from_status": null,
"to_status": "initiated",
"reason_code": null,
"actor": "api",
"payload": { "routing": { "provider": "simulator", "corridor_id": "cor_2f1a", "priority": 1 } },
"created_at": "2026-09-01T10:15:00Z"
},
{
"from_status": "initiated",
"to_status": "pending",
"reason_code": null,
"actor": "api",
"payload": { "scenario": "success" },
"created_at": "2026-09-01T10:15:00Z"
},
{
"from_status": "pending",
"to_status": "processing",
"reason_code": null,
"actor": "simulator",
"payload": {},
"created_at": "2026-09-01T10:15:04Z"
},
{
"from_status": "processing",
"to_status": "completed",
"reason_code": null,
"actor": "simulator",
"payload": {},
"created_at": "2026-09-01T10:15:08Z"
}
],
"has_more": false
},
"request_id": "7e1f0a92-6c53-4b8d-a041-2f9c8e7d6b5a"
}Hosted checkout and payment links
Collections can also be created without an API key through payment links: the payer opens pay.capayglobal.com/l/<pl_id> and the hosted checkout calls POST /v1/checkout/initiate, which runs this exact create pipeline on the payer's behalf. The payer's page then polls the public GET /v1/checkout/status/{id}, which answers a payer-safe projection (status, reason_code, and on completion receipt_ref: the operator's own transaction id, the number in the payer's SMS). Both endpoints are IP rate-limited and are documented in the Payment links & checkout guide.
POST/v1/checkout/initiate
GET/v1/checkout/status/{id}
Status lifecycle
A collection starts at initiated the instant the intent row is written, moves to pending once dispatched (a mobile money prompt is awaiting the payer), then processing, and settles at a terminal status. Terminal statuses are completed, failed, cancelled, expired and reversed; enqueued and in_reconciliation are intermediate states you may also observe. Always handle each status explicitly rather than assuming success. A dispatch that has not resolved within 20 seconds returns 201 pending with requires_requery set true: the reconciler requeries and the truth arrives by webhook.
initiated -> pending -> processing -> completed
\-> failed | expired | cancelledWebhooks
Each transition emits a signed webhook to your active endpoints subscribed to that type. The body is the event envelope; its data field carries a compact snapshot of the collection (id, status, amount, currency, country, method, reason_code, merchant_reference, livemode). collection.completed is the one that confirms the money landed. Verify the X-Capay-Signature header against the raw body before trusting a delivery: see the Authentication guide.
| Event | Status | When |
|---|---|---|
| collection.pending | pending | Dispatched and awaiting the payer or the provider. |
| collection.processing | processing | The provider accepted it and is settling the transaction. |
| collection.completed | completed | The money was collected. This is the confirmation to fulfill the order. |
| collection.failed | failed | The collection failed; reason_code says why. |
| collection.expired | expired | The payer did not complete it before the intent expired. |
| collection.cancelled | cancelled | The collection was cancelled before a provider outcome. |
POST https://your-app.example.com/webhooks/capay
X-Capay-Signature: t=1767225708,v1=6f8b4c2d9e0a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f0a2b4c6d8e0f1a3b5c
X-Capay-Event-Id: evt_2n4Kq8Zr41Xa
X-Capay-Event-Type: collection.completed
Content-Type: application/json
{
"id": "evt_2n4Kq8Zr41Xa",
"object": "event",
"type": "collection.completed",
"created_at": "2026-09-01T10:15:08Z",
"data": {
"id": "col_7Qm3k1Xa9Zr4",
"object": "collection",
"status": "completed",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"reason_code": null,
"merchant_reference": "order-10482",
"livemode": false
}
}Sandbox behavior
In the sandbox the collection never touches a real provider: a simulator drives it through the lifecycle. Send an X-Scenario-Key header to force a named scenario, or let the last four digits of the msisdn pick one. Any unmatched number resolves to success. See the Sandbox guide for the full scenario catalog.
| MSISDN ending | Scenario |
|---|---|
| …0001 | success |
| …0002 | failed.insufficient_funds |
| …0003 | failed.invalid_customer |
| …0004 | failed.payer_timeout |
| …0005 | expired |
| …0006 | enqueued.then_success |
| …0007 | in_reconciliation.then_resolved |
| …0008 | webhook.duplicate |
| …0009 | success.late |
Errors
These are the typed error codes the collections endpoints can return, on top of the shared envelope. A 5xx is always safe to retry with the same Idempotency-Key.
| Code | HTTP | When |
|---|---|---|
| validation_error | 422 | A body field is missing or malformed; validation_errors names each one. |
| method_not_available | 422 | method is card, or no open corridor serves the requested method. |
| unsupported_country | 422 | No open corridor serves this country. Check GET /v1/providers. |
| unsupported_currency | 422 | No open corridor serves this currency. Check GET /v1/providers. |
| card_data_rejected | 422 | The body contained card data (PAN, CVV or expiry), which this API never accepts. |
| prohibited_corridor | 403 | This country and currency pair is blocked by platform policy. |
| limit_exceeded | 403 | A configured merchant, country, provider or platform limit was hit. |
| risk_rejected | 403 | The risk engine declined the transaction. No money moved. |
| insufficient_scope | 403 | The API key lacks the collections:read or collections:write scope for this call. |
| invalid_api_key | 401 | The key is missing, malformed, revoked, expired or for the wrong environment. |
| resource_not_found | 404 | No collection matches this id in the key's environment. |
| idempotency_key_reused | 422 | This Idempotency-Key was already used with a different request body. |
| request_in_flight | 409 | A request with this Idempotency-Key is still being processed. |
| rate_limited | 429 | The app rate limit was exceeded; see the X-RateLimit-* headers. |
| corridor_closed | 503 | The corridor is temporarily closed. Retry later. |
| service_paused | 503 | Collections are temporarily paused on the platform. |
| provider_unavailable | 503 | No provider is currently available for this corridor. |
| internal_error | 500 | Something failed on our side. Retry with the same Idempotency-Key. |