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.

8 min to implement · Verified against the v4 API on 2026-10-11

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 payoutId and it stays pending or processing. 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 payoutId as soon as you receive it.
  • An agreed escalation contact at Orangepill for payouts that do not reach a final status.

Flow

  1. Payout request POST /v4/payouts
  2. Provider attempt Sent to the rail
  3. Timeout Outcome unknown
  4. Read the payout Same key or stored payoutId
  5. Terminal evidence completed / failed / cancelled
Never create a new payout to answer a timeout. A new idempotency key means a second payout.

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.

The original request
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.

Response to the repeated request
{
  "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

Read the payout
curl "$ORANGEPILL_API/v4/payouts/5b9e0f6d-…" \
  -H "Authorization: Bearer $ORANGEPILL_API_KEY"
Response
{
  "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.

Recover after a timeout (JavaScript)
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 failed with errorCode INITIATE_FAILED and 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 no providerPayoutId, 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 failed with errorCode RETRY_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.

When to escalate (JavaScript)
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 pending even when the payout has completed. Read GET /v4/payouts/{payoutId}.
  • You treat RETRY_EXHAUSTED as 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_key in 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 failed and 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 pending or processing.
  • 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 failed always means the money did not move. With RETRY_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 payoutId as 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 pending with no providerPayoutId for more than a few minutes, and on any RETRY_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.