Implementation Guide
Handle a payout timeout safely
Your request timed out. Find out whether the payout exists and what state it is in, without sending the money twice.
The rule to remember: a timeout tells you nothing about the money. Do not create a new payout.
Read the one you already have, or send the same request again with the same idempotency_key. Act only
on completed, failed or cancelled.
The job
A remittance company is paying 850,000 COP to a beneficiary's bank in Colombia. The sender is waiting for confirmation. Your call to create the payout hangs for 30 seconds and your HTTP client gives up.
Did the beneficiary receive the money? At this point you do not know, and neither does your code. The payout may not exist. It may exist and be waiting for the provider. It may already be complete. The job is to find out which, without sending the money twice.
There are two different timeouts here, and they need different handling:
- Your request to Orangepill timed out. You do not have a
payoutId, or you are not sure the request arrived. This is a problem between your server and the API. - The provider has not answered. You have a
payoutIdand it stayspendingorprocessing. Orangepill sent the payout, or tried to, and the rail has not given a final answer.
Before you start
- A working payout integration. If you do not have one yet, start with Build your first payout.
- An idempotency key that you generate and store before every payout request, together with the request body.
- A place to store the
payoutIdas soon as you receive it. - An agreed escalation contact at Orangepill for payouts that do not reach a final status.
Flow
- Payout request POST /v4/payouts
- Provider attempt Sent to the rail
- Timeout Outcome unknown
- Read the payout Same key or stored payoutId
- Terminal evidence completed / failed / cancelled
Implementation
1. Your request to Orangepill timed out
Your client gave up before a response arrived. Orangepill may have created the payout anyway: the request may have been processed and only the response was lost.
curl -X POST "$ORANGEPILL_API/v4/payouts" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
--max-time 30 \
-d '{
"idempotency_key": "remit-2026-10-11-00871",
"product_key": "bank_transfer.bre_b",
"amount": { "value": "85000000", "currency": "COP" },
"recipient": { "scheme": "ALIAS", "value": "<beneficiary Bre-B key>" },
"source": { "type": "wallet", "wallet_account_id": "<wallet account id>" },
"metadata": { "transfer_ref": "TR-00871" }
}'
curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received
If you stored a payoutId from an earlier response, skip to step 2. If you did not, send the same
request again with the same idempotency_key. Orangepill looks the key up in your tenant. If a payout
with that key exists, you get it back and nothing new is created. If none exists, the payout is created now, once.
{
"success": true,
"data": {
"payoutId": "5b9e0f6d-…",
"status": "pending",
"reservationId": "a41c7e22-…"
}
}
Store the payoutId. Ignore the status in this response: for a repeated key it is
currently always pending, whatever state the payout is really in. The real status comes from the next
call.
2. Read the existing payout
curl "$ORANGEPILL_API/v4/payouts/5b9e0f6d-…" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" {
"success": true,
"data": {
"id": "5b9e0f6d-…",
"idempotency_key": "remit-2026-10-11-00871",
"status": "processing",
"amount": { "value": "85000000", "currency": "COP" },
"providerPayoutId": "<provider reference>",
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-10-11T14:02:11Z",
"completedAt": null
}
} processing with a providerPayoutId means the provider accepted the payout and has not
reported a result. The money may be on its way. Keep reading the payout with backoff, as in the polling sketch in
the first payout guide. There are no payout webhooks today, so polling is the only way to learn the outcome.
3. A sketch of the recovery path
This is the logic to run when a payout request times out, after a crash, or when a worker picks up a payout record it does not have a final answer for. It uses only the two payout routes.
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
// record is your own row, written BEFORE the first request:
// { idempotencyKey, requestBody, payoutId (null until you saw a response) }
async function recoverAfterTimeout(record) {
let payoutId = record.payoutId;
if (!payoutId) {
// You never saw a response. Send the same body with the same key.
// If the payout exists you get it back. If it does not, it is created now, once.
const res = await fetch(`${API}/v4/payouts`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify(record.requestBody), // includes record.idempotencyKey
});
if (!res.ok) throw new Error(`resend failed: ${res.status}`); // try again later, same key
const { data } = await res.json();
payoutId = data.payoutId;
await savePayoutId(record.idempotencyKey, payoutId);
// Do not read data.status here. For a repeated key it is always "pending".
}
const res = await fetch(`${API}/v4/payouts/${payoutId}`, { headers: auth });
const { data: payout } = await res.json();
if (payout.status === 'failed' && payout.errorCode === 'RETRY_EXHAUSTED') {
return { outcome: 'unconfirmed', payout }; // checks ran out: escalate, do not resend
}
if (TERMINAL.has(payout.status)) {
return { outcome: payout.status, payout }; // final: act on it
}
return { outcome: 'unknown', payout }; // pending / processing: keep reading
} Run one recovery at a time per key. Two concurrent requests with the same new key can race, and the loser may get an error instead of the existing payout. Wait, then read again.
4. The provider has not answered
This is the harder case. You have a payoutId, and the payout stays pending or
processing. Here is what Orangepill does today while you wait:
-
If the provider rejects the request outright, the payout becomes
failedwitherrorCodeINITIATE_FAILEDand the reservation is released. -
If Orangepill's own call to the provider times out, the connection drops, or the provider answers with a server
error, Orangepill does not know whether the provider created the transfer. It does not resend the payout and
does not mark it failed. The payout stays
pending, with noproviderPayoutId, and is held for operator review. -
If the provider accepted the payout, Orangepill checks with the provider for a final status, with growing
intervals between checks. If the checks run out without an answer, the payout becomes
failedwitherrorCodeRETRY_EXHAUSTED. That code means Orangepill stopped asking. It is not a decline from the provider, and the money may still have arrived.
In each of these cases, the correct action on your side is the same: do not create another payout, and escalate the existing one.
5. Escalate before the reservation expires
A wallet-funded payout records a reservation that currently expires after one hour. The expiry runs whatever the
payout state is, so a payout still pending or processing after an hour no longer has a
reservation. That reservation does not reduce available balance today either, so your own records must stop the
same value being paid out twice. Escalate well before the hour.
const ESCALATE_AFTER_MS = 15 * 60 * 1000; // pick per rail; must be well under 1 hour
function needsEscalation(payout, now = Date.now()) {
const age = now - Date.parse(payout.createdAt);
const open = payout.status === 'pending' || payout.status === 'processing';
const unconfirmed = payout.status === 'failed' && payout.errorCode === 'RETRY_EXHAUSTED';
return unconfirmed || (open && age > ESCALATE_AFTER_MS);
}
Send Orangepill the payoutId, your idempotency_key, createdAt, the last status and error code you saw, and the beneficiary reference you expect. Tell the sender the transfer is being confirmed. Do not tell them it failed.
Where this is going: Resolve In Development
A payout whose outcome is unknown, or where Orangepill and the provider disagree, is the class of problem Resolve is meant to govern. The intended sequence is Investigate, Establish, Decide, Verify, ending in one of Continue, Wait, Remediate or Escalate. Resolve is not available today and has no API. Until it is, the safe path is the one above: read, wait, escalate.
What can go wrong
- You retry with a new idempotency key. That is a second payout. If the first one completes too, the beneficiary is paid twice. Always reuse the stored key.
- You reuse a key with a different body. Orangepill matches on the key alone. A repeated key returns the first payout even if the amount or recipient changed, so a correction needs a new key and a new payout, created only after the first one is final.
- You trust the status from a repeated create. It says
pendingeven when the payout has completed. ReadGET /v4/payouts/{payoutId}. - You treat
RETRY_EXHAUSTEDas a decline and pay again. The provider may have executed the payout. Escalate and wait for confirmation first. - You wait silently for more than an hour. The reservation is released while the payout is still open, and nothing in Orangepill stops the same value being sent again except your idempotency key and your own records.
What Orangepill guarantees today
- One payout per
idempotency_keyin your tenant. Repeating the request returns the existing payout. GET /v4/payouts/{payoutId}returns the payout's current status, error code and provider reference.- When Orangepill's call to the provider times out or fails in a way that leaves the outcome unknown, Orangepill does not resend the payout on its own and does not mark it failed.
- A provider rejection marks the payout
failedand releases the reservation, with no ledger posting.
What Orangepill does not guarantee
- Funds held while the outcome is unknown. A payout reservation currently expires after one hour, even if the payout is still
pendingorprocessing. - Automatic payout failover. A payout is not retried on another provider. This is in development.
- Payout webhooks. There are none today. You learn the outcome by reading the payout.
- That
failedalways means the money did not move. WithRETRY_EXHAUSTED, the provider never confirmed either way. - Automatic resolution of unknown outcomes. Resolve is in development and has no API.
Production considerations
- Write the idempotency key and request body to your database before sending the request, and the
payoutIdas soon as you have it. - Set a client timeout that suits you, and make every timeout path call the recovery logic, never the create logic with a fresh key.
- Set an escalation threshold per rail from what you observe in sandbox and early production, and keep it well under one hour.
- Alert on payouts that are
pendingwith noproviderPayoutIdfor more than a few minutes, and on anyRETRY_EXHAUSTED. - Test in sandbox: kill your client mid-request and recover, repeat a request with the same key, and read a payout that stays non-terminal.
- Give your support team a view with the payout status, error code, provider reference and age, so they can answer the sender without guessing.
The full list is in the production checklist.
Build this with us.
Sandbox access and API credentials are provided during evaluation.