Refunds
Create a refund with POST /v1/payments/{id}/refunds, an Idempotency-Key and { "amount": 100, "reason": "Customer return" } (a reason of 1 to 300 characters). The amount uses the original payment currency and minor units. CDF must be a whole-franc amount. The original payment must have been approved no more than 180 days ago (409 REFUND_WINDOW_EXPIRED). Pending and completed refunds together cannot exceed its amount (409 REFUND_AMOUNT_EXCEEDED); a payment not approved answers 409 PAYMENT_NOT_REFUNDABLE and a bank payment 409 REFUND_METHOD_UNSUPPORTED. From 0.44.0 a refund that would take the day’s refunds past the refunds-per-day limit of your risk profile answers 409 MERCHANT_LIMIT_EXCEEDED (in the payment’s currency, the refunds released that Kinshasa day — approved that day if they waited for approval, else asked for that day — that completed or are in flight, and those still waiting for approval on the day they were asked); the portal’s refund (POST /portal/merchants/{merchant}/payments/{id}/refunds) answers it too, and so does approving a refund that waited (POST /portal/merchants/{merchant}/refunds/{id}/approve), which then stays waiting. The limit is set by Futa’s compliance or risk analysts and its value is not published: wait for the next Kinshasa day, or contact Futa support if it does not fit your business. In the sandbox its value is synthetic. These refusals create nothing: send a new request, with a new key, once the cause is fixed. Refunds always return to the original issuer account; a destination cannot be supplied.
Use a key with refunds:write to create and refunds:read to read GET /v1/refunds/{id} or GET /v1/payments/{id}/refunds. Existing keys retain their scopes; request a separately approved staff recovery key with refund permissions when needed. Key rotation does not add permissions.
The accepted refund starts QUEUED with AWAITING_FUNDS. A durable workflow reserves unsettled funds before moving to PROCESSING. If funding is insufficient it stays queued. Unknown issuer outcomes become UNCERTAIN: do not create a replacement request. Exact POST retries return the original response; use GET for current status. After 15 minutes without a final outcome the refund is flagged for operations, with funds still reserved and status queries continuing.
Only authoritative issuer success produces COMPLETED, balanced compensating entries and refund.completed. A confirmed issuer failure produces FAILED and frees the reservation. Duplicate requests and workflow retries use the same issuer refund ID. Original payment postings remain sealed. Payment reads retain the original authorization state and expose aggregate refund_status and refunded_amount with payments:read. Embedded refunds and refunds_next_cursor are present only when the authenticated key also has refunds:read; clients cannot enable history through a query parameter. Authorized history contains up to 100 recent linked refunds. Totals include all completed refunds. Follow refunds_next_cursor using the refund list endpoint; subsequent pages expose next_cursor and accept ?cursor=<id>. Checkout and its text receipt display the completed refund amount.
Refund lifecycle events use the existing HMAC-signed webhook delivery and replay APIs. data.id identifies the refund, data.payment_id its original payment, and data.sequence orders that refund’s events. Deduplicate by event UUID. Completed refunds also produce a durable synthetic receipt record; no external SMS is sent.
Approval above a threshold
Section titled “Approval above a threshold”The merchant’s Owner sets one refund threshold per currency in the portal (« Paramètres › Remboursements »): none, zero or an amount in minor units (whole francs in CDF). With no threshold every refund goes at once; at zero every refund needs approval. A refund waits when the payment’s refunds still counting (queued, processing, uncertain, completed or waiting), this one included, exceed the threshold, so splitting a refund does not avoid it.
A refund that waits is created PENDING_APPROVAL with AWAITING_APPROVAL and an approval object (expires_at, requested_by, approved_by, rejected_by, decided_at). Its amount counts against what remains refundable, but nothing is dispatched. A person other than the requester (an Owner or Admin, after a recent sign-in) approves it in the portal; it then continues as QUEUED like any refund. A rejection, or seven days without a decision, ends it as REJECTED or EXPIRED and frees its amount. Only a person decides: a key never approves or rejects (403 REFUND_APPROVER_PERSON_REQUIRED), and a refund asked for with an API key is never approved by whoever created or rotated that key, following keys made with other keys back to people (403 REFUND_SECOND_APPROVER_REQUIRED); when the key’s holders are not all known people, such as a staff-provisioned key, the refund can only be rejected (403 REFUND_REQUESTER_UNKNOWN). Approval also needs the payment to be within its 180-day refund window (409 REFUND_WINDOW_EXPIRED); a request past it can still be rejected. The portal decides with POST /portal/merchants/{merchant}/refunds/{id}/approve or /reject and { "expected_version": <sequence> }; a stale version answers 409 REFUND_VERSION_CHANGED, the requester’s own approval 403 REFUND_SECOND_APPROVER_REQUIRED. Threshold changes are versioned (expected_version, 409 THRESHOLD_VERSION_CHANGED) and every change is kept; saving the amount already set answers the current threshold, so a save whose answer was lost can be sent again. Webhooks announce refund.pending_approval, refund.rejected and refund.expired besides the other refund events; subscribe to them in the endpoint’s configuration.
The local simulator supports successful, declined, pending, unavailable and lost-response outcomes using internal test controls. Merchant funding holds are test fixtures for future settlement/reserve integration, not a live balance administration API. Real participant certification, actual SMS and sponsor settlement remain separate work.