Flominzo

Developer guide

My payout timed out. Did it go through? How to never pay the same payout twice.

A payout timeout is an unknown outcome, not a failure. Query the status first, check settlement and bank evidence, then decide. Never resend blind.

By , Founder · Last updated:

See the Payouts API
On this page 16 sections

The short answer

When a payout request times out, you do not know whether money moved. Record the outcome as unknown, ask the provider for the status by your own reference, check the settlement file and your bank statement, and only then decide. Never send the instruction again as a new request. A blind retry after a timeout is the most common way payout systems pay the same recipient twice.

Idempotency keys reduce the risk, but only if the provider honours them and your system reuses the same key, with the same request body, for the same logical payout. The safe design treats an unknown outcome as a state of its own, with a defined path to resolution and an owner when that path runs out.

Why is a timeout not a failure?

A request can fail at three different points, and a timeout looks the same from your side in all three:

  1. The request never reached the provider. No money moved.
  2. The provider received it and rejected it, and the rejection was lost. No money moved.
  3. The provider received it and processed it, and the success response was lost. Money moved.

From the client, all three look like a request that ended without a usable answer. Treating that as a failure assumes the first or second case. If it was the third, a retry sends a second payout. The same applies to a connection reset or a server error returned after the request was sent: unless the provider documents that the error means nothing happened, treat it as unknown.

How do blind retries pay twice?

There are four common routes to a duplicate payout:

  • The provider accepted the payout before the timeout. Your retry is a second instruction, and the provider pays it.
  • Retries without a key. Without an idempotency key the provider has no way to know the second request is the first one again.
  • A new key per retry. Generating the key per HTTP attempt instead of per payout defeats it: every retry looks new.
  • The same key with a different body. A retry that changes the amount, currency or beneficiary under the same key is a different instruction pretending to be a repeat. It must be rejected, not paid.

Recovering a duplicate usually means asking the recipient or their bank to return funds, which is slow and not always possible. Mobile wallets and cash pickups are especially hard to recover once the money is used. Prevention is far cheaper than recovery.

The status-query-before-retry protocol

  1. Record the attempt as unknown. Store the outcome as UNKNOWN, not failed, together with the idempotency key and your client reference.
  2. Block every retry path. While the outcome is unknown, nothing may send this instruction again: not a retry loop, not a queue redelivery, not an operator, not an AI agent retrying a tool call.
  3. Ask the provider. Query the payout status by your client reference or idempotency key. If the provider knows the payout, record its answer as a claim.
  4. Wait for the callback. If the lookup is inconclusive, wait for the provider’s webhook within its declared window, and look up again at intervals you agree per rail and provider.
  5. Check settlement evidence. The provider’s settlement file or balance statement, and then your bank statement, show whether the payout was charged and whether cash moved.
  6. Retry only on evidence of no action. Send again only if the provider confirms it never received the instruction or rejected it as retryable, and then with the same key and an identical body.
  7. Escalate when the window runs out. If no evidence arrives, raise an exception with an owner. A person decides with the evidence in front of them, and the decision is recorded with its reason.

Lookup and escalation windows differ by rail and provider, so agree them per provider and keep them in configuration, not in code. For example, you might look up a few times in the first minutes after the timeout, then at longer intervals until the provider’s callback window closes, then check the next settlement file.

Real windows come from each provider’s documentation and your agreement with them.

Idempotency keys done right

HTTP defines some methods as idempotent, meaning a repeated request has the same effect as one request. POST, which most payout APIs use to create a payout, is not idempotent by definition (RFC 9110). The IETF Idempotency-Key draft describes the common pattern: the client sends a unique key with the request, and the server uses it to recognise a repeat and return the original result instead of acting again.

  • One key per payment intent, not per HTTP attempt. One logical payout has one key, and every retry of it reuses that key.
  • Store the key before sending. If your process crashes mid-request, the key must survive so a later lookup or retry can use it.
  • Reuse it with an identical body. A retry carries exactly the same amount, currency, beneficiary and reference as the first attempt.
  • Reject the same key with a different body. On your own API, a reused key with a changed payload is an error, never a new payout. The IETF draft describes this case as an error too.
  • Know the provider’s rules. Providers differ on whether they support keys, how long they remember them and which endpoints honour them. A key that has expired at the provider gives no protection, so lookup and settlement evidence are still needed.

Webhooks are evidence, not the truth

Payment webhooks are usually delivered at least once. That means a callback can arrive late, arrive twice, arrive out of order, or not arrive at all.

  • Verify before you trust. Check the signature, the source and the freshness of every callback before interpreting it.
  • Deduplicate by event. Store the provider’s event identifier and apply each event once, so a second delivery changes nothing.
  • Never move backwards. A late "processing" event must not overwrite a "succeeded" state that is already evidenced.
  • Look up when it goes quiet. A payout with no callback inside its declared window is looked up, not assumed.
  • Reconcile daily. Compare every payout against the provider’s report and your bank statement, because a webhook is one claim among several.

Payout status unknown and no webhook came: what now?

A missing webhook is the most common way a payout ends up with an unknown status. The request was accepted, the provider said it was processing, and then nothing. The payout is not failed and not paid; it is unknown until evidence says otherwise.

  1. Check your own logs first. Look for a callback that arrived but was rejected by signature, source or freshness checks, or that failed to parse. A rejected callback is still a clue.
  2. Look the payout up. Query the provider’s status by your client reference or idempotency key.
  3. Keep looking up within the provider’s window. Use the intervals the provider documents; many deliver callbacks with retries for a period after the event.
  4. Check the next settlement file. If the payout is in it, money left the provider, whatever the missing webhook suggests.
  5. Raise it when the window closes. A payout with no callback and no conclusive lookup becomes an exception with an owner, with every lookup attached.

In Flominzo this raises a MISSING_CALLBACK exception when an accepted payout gets no callback or status change in its declared window, and a STALE_STATUS exception when a state stops moving. The payout state machine guide covers polling windows per rail, and the free stuck payout checklist sets out the checks in order.

A payout state machine

A payout attempt moves through explicit states. These are the attempt states documented in the Flominzo documentation, with what each means for a retry.

StateWhat it meansSafe to send again?
RECORDEDThe attempt and its key are stored. Nothing has been sent yet.Send once, with the stored key.
SENTThe request left your system. No answer yet.No. Wait for the answer or look it up.
ACCEPTEDThe provider acknowledged the instruction.No.
PROCESSINGThe provider is working on it.No.
SUCCEEDEDThe provider reports the payout completed.No. Wait for settlement and bank evidence.
FAILEDThe provider rejected it, and the rejection is evidenced.Only with the same key if the result was retryable. Otherwise fix the cause first.
UNKNOWNThe outcome has not been established.No. Look it up and gather evidence first.
CANCELLEDCancelled before execution, and confirmed.Not as a retry. A new, approved instruction if still needed.
REVERSEDPaid, then returned or reversed.No. Reconcile the reversal and investigate first.

Failure modes and what to check

What happenedCould money have moved?What to checkException raised
Timeout after sendingYesStatus lookup by your reference, then settlement and bank evidenceUNKNOWN_UNRESOLVED if the window passes
Server error after sendingPossiblyTreat as a timeout unless the provider documents that nothing happenedUNKNOWN_UNRESOLVED if unresolved
Duplicate webhookNo new moneyDeduplicate by event identifier; apply onceNone, unless two provider references appear
Callback never arrivesPossiblyStatus lookup, then the provider’s reportMISSING_CALLBACK
API says paid, webhook says failedUnclearSettlement file and bank statement decideCLAIM_MISMATCH
Shows sent, recipient never received itUnclearStatus age against the expected window, the settlement file, the recipient’s bankSTALE_STATUS
Reversed after successMoved, then returnedLink the reversal to the original payout; reopen itREVERSAL_AFTER_SUCCESS
Two provider references for one attemptPossibly twiceSettlement and bank evidence: charged once or twice?DUPLICATE

AI agents make this worse

AI agents retry tool calls when something times out or returns an error. If the tool creates a payout, an agent can reproduce the classic double payment faster than any person. The fix is the same discipline, applied per agent step:

  • One payment intent, with one idempotency key, for each agent step that means to pay.
  • A status query before any retry, never a second create.
  • The agent proposes; the payment layer decides. The agent cannot resend a payout or override idempotency.

Read more in payment permissions and approvals for AI agents.

A decision flow in pseudo-code

// One payout attempt. The idempotency key belongs to the instruction.
key = instruction.idempotencyKey               // stored before the first send
result = adapter.createPayout(instruction, key)

switch (result.class) {
  case SUCCESS:
    record(attempt, SUCCEEDED, result.providerReference)
    expect(settlementLine, cashMovement)       // closure still needs evidence
  case NON_RETRYABLE:
    record(attempt, FAILED, result.reason)      // do not send again
  case RETRYABLE:
    retry(instruction, key)                     // same key, same body, bounded
  case UNKNOWN_OUTCOME:
    record(attempt, UNKNOWN)
    status = adapter.getPayoutStatus(instruction.clientReference)
    if (status.isKnown) record(attempt, status)
    else await webhook or settlement evidence within the agreed window
    if (window expired) raise UNKNOWN_UNRESOLVED  // a person decides, with evidence
    // never: createPayout(instruction, newKey)
}

Simplified pseudo-code; it doesn’t describe a specific API.

What if the provider reports two references?

Sometimes the duplicate is already on the provider’s side: two provider references, or two settlement lines, for one attempt. Flominzo raises this as a DUPLICATE exception.

  • Check the settlement and bank evidence to see whether the provider charged once or twice.
  • If it charged once, the second reference is a reporting duplicate and is resolved with that evidence.
  • If it charged twice, the recipient was likely paid twice. Recovery follows the provider’s return process, and a person approves each step.
  • Any ledger correction is a new, explained entry. The original records are never edited.

A checklist for your payout integration

  • Generate one idempotency key per logical payout, and persist it before the first attempt.
  • Record timeouts, connection resets and server errors after sending as unknown, not failed.
  • Implement a status lookup by your own reference before any retry logic.
  • Reject a reused key with a changed request body.
  • Verify webhook signatures and freshness, deduplicate by event, and never let a late event move a payout backwards.
  • Reconcile against the provider’s settlement file and your bank statement, not only its API.
  • Alert on unknown attempts that outlive the lookup window, and give each one an owner.
  • Never refund the sender for a payout whose outcome is still unknown.

How Flominzo handles it

In Flominzo, the attempt is recorded before anything is sent, and every adapter result is classified as SUCCESS, RETRYABLE, NON_RETRYABLE or UNKNOWN_OUTCOME. UNKNOWN is never a terminal state. It is resolved by a status lookup, settlement evidence or a recorded human decision, and never by sending the instruction again.

Callbacks are verified before they are interpreted. The payment keeps the original instruction, each attempt with its idempotency key and provider reference, and the evidence that resolved it, and every decision keeps its reason. The payout is then reconciled against provider, settlement and bank evidence before it is closed. Agents can request a status lookup through the same controlled commands a person would use, but they never resolve an unknown outcome by assumption or override idempotency.

Questions

Should we mark a timed-out payout as failed and refund the sender?

No. A timeout means the outcome is unknown. Refunding the sender while the recipient may have been paid can lose the full amount. Resolve the outcome first, then refund only if the payout is evidenced as failed or returned.

Is an idempotency key enough on its own?

No. It only helps if the provider supports it on that endpoint, still remembers the key, and receives the same key and body on every retry. Lookup and settlement evidence are still needed to prove what happened.

How long should we wait before escalating an unknown payout?

There is no universal number. Use each provider’s declared callback and settlement windows, and escalate when an attempt outlives them. The window belongs in provider configuration, not in code.

Can an AI agent resolve an unknown payout?

An agent can request a status lookup and summarise the evidence. In Flominzo it cannot resolve an unknown outcome by assumption, resend a payout, or close the exception; a person or an agreed rule does that.

Sources

Let’s make it specific to you.

Bring your systems, payment flows, and questions. We’ll help define the next step.

Talk to the team