Refunds
Refund a completed collection, in full or in part, back to the payer. Refunds are machine-written intents: the amount is reserved against your balance the moment you create one, and the database guarantees you can never refund more than a collection actually captured.
How refunds work
You can only refund a collection whose status is completed. The refundable balance is the collection net amount (the captured amount minus fees), less any refunds already outstanding against it. Partial refunds accumulate: several partial refunds against one collection are allowed, and their sum can never exceed that net. The cap is enforced in the database with a row lock on the collection, so concurrent partial refunds can never race past it.
Every refund posts through the ledger. A reserve journal (rf:{id}:reserve) holds the funds when the refund is created; a settle journal (rf:{id}:settle) or a reverse journal (rf:{id}:reverse) closes it out when the refund completes or is released. If the reserve cannot be covered by your available balance, the refund fails with insufficient_balance and no money moves.
Amounts are decimal strings in the collection currency, which the refund always inherits: you never send a currency field. Zero-decimal currencies such as XAF and XOF are whole numbers, so 3,000 XAF is "3000", never "3000.00" and never 300000.
This resource is create-and-read only: there is a create endpoint and a retrieve-by-id endpoint, and no list endpoint. Track your refunds by the ids you receive, or watch the refund.completed and refund.failed events.
Create a refund
Reserve and dispatch a refund against a completed collection. The reserve is taken immediately, before any provider call.
In the sandbox the refund runs end to end against the payment simulator and resolves on its own. In production, refund provider adapters are still rolling out: a live refund currently reserves, then declines with provider_unavailable and releases the reserve.
Headers
Authorization: Bearer sk_...- Your secret key. The refunds:write scope is required.Idempotency-Key- Required on this money-moving call. A unique key (1 to 255 characters) that makes the request safe to retry: resending the same key with the same body replays the original response instead of creating a second refund.Content-Type: application/json- The request body is JSON.
Request body
| Parameter | Type | Description |
|---|---|---|
| collection_idrequired | string | Public id (col_...) or UUID of the completed collection to refund. Must belong to your merchant and environment. |
| amountoptional | string | Amount to refund, a decimal string in the collection currency. Omit to refund the full remaining refundable balance. |
| reasonoptional | string | Free-text reason stored on the refund for your dashboard (truncated to 500 characters). Never shown to the payer. |
Example request
curl -X POST https://api.capayglobal.com/v1/refunds \
-H "Authorization: Bearer sk_test_8fK2q0Zr41Xa" \
-H "Idempotency-Key: 6f1a9c2e-4b7d-4a1e-9c33-2d5f8a0b1c2e" \
-H "Content-Type: application/json" \
-d '{
"collection_id": "col_7pQ2r9Xm4KdA",
"amount": "3000",
"reason": "Customer returned item"
}'Example response
{
"status": "success",
"data": {
"id": "rf_3mB8t1Yq5Le0",
"object": "refund",
"collection": "col_7pQ2r9Xm4KdA",
"status": "processing",
"amount": "3000",
"currency": "XAF",
"reason": "Customer returned item",
"reason_code": null,
"rail": "provider",
"provider": "pawapay",
"payout": null,
"expected_resolution_at": "2026-08-29T12:49:56.000Z",
"livemode": false,
"created_at": "2026-08-29T12:34:56.000Z",
"completed_at": null
},
"request_id": "b1e2c3d4-5f60-4a71-8b92-c0d1e2f3a4b5"
}Omit amount for a full refund of everything still refundable; pass amount for a partial refund. A refund whose amount exceeds the remaining refundable balance is rejected with validation_error, and the database over-refund guard is the final backstop.
The response is the refund object with a 201 status. A refund created in the sandbox returns status "processing" (or "pending" if the simulator dispatch is slow) and settles to "completed" shortly after; retrieve it to see the final state.
Retrieve a refund
Fetch a single refund by its id.
Headers
Authorization: Bearer sk_...- Your secret key. The refunds:read scope is required.
Path parameters
| Parameter | Type | Description |
|---|---|---|
| idrequired | string | Refund public id (rf_...) or UUID, in the path. |
Example request
curl https://api.capayglobal.com/v1/refunds/rf_3mB8t1Yq5Le0 \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
Example response
{
"status": "success",
"data": {
"id": "rf_3mB8t1Yq5Le0",
"object": "refund",
"collection": "col_7pQ2r9Xm4KdA",
"status": "completed",
"amount": "3000",
"currency": "XAF",
"reason": "Customer returned item",
"reason_code": "refund_settled",
"rail": "payout",
"provider": "pawapay",
"payout": "po_5rD0v3Ws7Ng2",
"expected_resolution_at": null,
"livemode": false,
"created_at": "2026-08-29T12:34:56.000Z",
"completed_at": "2026-08-29T12:35:11.000Z"
},
"request_id": "c2d3e4f5-6a71-4b82-9c03-d1e2f3a4b5c6"
}The refund object
Both endpoints return the same object.
| Field | Type | Description |
|---|---|---|
| id | string | Refund id, prefixed rf_. |
| object | string | Always "refund". |
| collection | string | Public id (col_...) of the collection this refund is against. |
| status | string | Current status. See refund statuses below. |
| amount | string | Refunded amount, a decimal string in the collection currency. |
| currency | string | 3-letter ISO currency, inherited from the collection. |
| reason | string | null | The reason you supplied, or null. |
| reason_code | string | null | Normalized machine reason code once terminal, or null. |
| rail | string | How the money travels back: "provider" when the capturing provider refunds natively, "payout" when a linked carrier payout returns it over an open payout corridor. We choose the rail; you never request one. |
| provider | string | null | The provider moving the money on this rail. |
| payout | string | null | Public id (po_...) of the carrier payout delivering a payout-rail refund, or null on the provider rail. The carrier moves no money of its own: every ledger entry belongs to the refund. |
| expected_resolution_at | string | null | ISO 8601 estimate of when this refund should reach a final status; null once it is terminal. |
| livemode | boolean | false in sandbox, true in production. |
| created_at | string | ISO 8601 timestamp of when the refund was created. |
| completed_at | string | null | ISO 8601 completion timestamp, or null until terminal. |
Refund statuses
A refund starts initiated, moves to processing (or pending while it waits on the provider, enqueued while a rail is down), and ends in one of the terminal states below. Every status below emits its own webhook event: refund.pending, refund.enqueued, refund.processing, refund.completed, refund.failed, refund.cancelled, refund.expired and refund.reversed.
initiated- The refund intent exists and the reserve is being taken.pending- Awaiting the provider (or parked by a dispatch timeout for the reconciler).processing- Accepted and being processed.enqueued- Held with the reserve intact while the rail recovers; it dispatches automatically when it does.completed- Terminal. The refund settled back to the payer.failed- Terminal. The refund failed and the reserve was released.cancelled- Terminal. The refund was cancelled before settlement.expired- Terminal. The refund was not completed before it expired.reversed- Terminal. A settled refund was reversed.
Errors
Errors use the standard envelope: a machine-readable code, a human message, and a next_step. Every error carries the request_id to quote to support.
| Code | HTTP | When it is returned |
|---|---|---|
| validation_error | 422 | collection_id is missing; the collection is not completed; it is already fully refunded; or amount is malformed or exceeds the refundable balance. A missing or over-long Idempotency-Key is reported here too. |
| resource_not_found | 404 | No collection (on create) or refund (on retrieve) matches the id in this environment. |
| insufficient_balance | 402 | Your available balance cannot cover the refund reserve. |
| service_paused | 503 | Refunds are paused platform-wide by an operational kill switch. No refund is created. |
| card_data_rejected | 422 | The request body contained card data (PAN, CVV or expiry), which this API never accepts. |
| 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. |
| insufficient_scope | 403 | The API key lacks the refunds:write (create) or refunds:read (retrieve) scope. |
| refund_unsupported_rail | 422 | Neither rail can serve this collection (no native refund at the provider, and no payout corridor that reaches the payer). Nothing was created and no balance was reserved: refund the payer another way and record it. |
| refund_below_rail_minimum | 422 | The amount is below the smallest refund the serving rail accepts. next_step names the minimum. Nothing was created and no balance was reserved. |
| refund_payer_details_missing | 422 | A bank collection whose payer account was never captured has no destination to return money to. Nothing was created and no balance was reserved. |
| corridor_awaiting_float | 503 | The payout corridor that would carry this refund is waiting for its float to be replenished. Temporary: retry later. Nothing was created and no balance was reserved. |
| provider_unavailable | 503 | Production only: no refund-capable provider is configured for the corridor; the reserve is released. A provider that simply times out or answers 5xx does NOT land here: the refund is created pending and resolved by our reconciler. |
| rate_limited | 429 | The app exceeded its request quota; see the X-RateLimit-* headers. |
| internal_error | 500 | Something failed on our side. Retry with the same Idempotency-Key. |