Capay Global

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

POST/v1/refunds

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

ParameterTypeDescription
collection_idrequiredstringPublic id (col_...) or UUID of the completed collection to refund. Must belong to your merchant and environment.
amountoptionalstringAmount to refund, a decimal string in the collection currency. Omit to refund the full remaining refundable balance.
reasonoptionalstringFree-text reason stored on the refund for your dashboard (truncated to 500 characters). Never shown to the payer.

Example request

curl
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

201 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

GET/v1/refunds/{id}

Fetch a single refund by its id.

Headers

  • Authorization: Bearer sk_... - Your secret key. The refunds:read scope is required.

Path parameters

ParameterTypeDescription
idrequiredstringRefund public id (rf_...) or UUID, in the path.

Example request

curl
curl https://api.capayglobal.com/v1/refunds/rf_3mB8t1Yq5Le0 \
  -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"

Example response

200 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.

FieldTypeDescription
idstringRefund id, prefixed rf_.
objectstringAlways "refund".
collectionstringPublic id (col_...) of the collection this refund is against.
statusstringCurrent status. See refund statuses below.
amountstringRefunded amount, a decimal string in the collection currency.
currencystring3-letter ISO currency, inherited from the collection.
reasonstring | nullThe reason you supplied, or null.
reason_codestring | nullNormalized machine reason code once terminal, or null.
railstringHow 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.
providerstring | nullThe provider moving the money on this rail.
payoutstring | nullPublic 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_atstring | nullISO 8601 estimate of when this refund should reach a final status; null once it is terminal.
livemodebooleanfalse in sandbox, true in production.
created_atstringISO 8601 timestamp of when the refund was created.
completed_atstring | nullISO 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.

CodeHTTPWhen it is returned
validation_error422collection_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_found404No collection (on create) or refund (on retrieve) matches the id in this environment.
insufficient_balance402Your available balance cannot cover the refund reserve.
service_paused503Refunds are paused platform-wide by an operational kill switch. No refund is created.
card_data_rejected422The request body contained card data (PAN, CVV or expiry), which this API never accepts.
idempotency_key_reused422This Idempotency-Key was already used with a different request body.
request_in_flight409A request with this Idempotency-Key is still being processed.
insufficient_scope403The API key lacks the refunds:write (create) or refunds:read (retrieve) scope.
refund_unsupported_rail422Neither 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_minimum422The 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_missing422A 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_float503The 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_unavailable503Production 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_limited429The app exceeded its request quota; see the X-RateLimit-* headers.
internal_error500Something failed on our side. Retry with the same Idempotency-Key.