Flominzo

Engineering guide

A payout state machine. Webhooks are not your source of truth.

Design a payout state machine that survives late, duplicate and missing webhooks: the states, allowed transitions, polling windows per rail and evidence.

By , Founder · Last updated:

Explore Flominzo Payouts
On this page 12 sections

The short answer

Model each payout attempt as an explicit state machine whose transitions are driven by evidence, not by whichever webhook arrived last. Webhooks, API responses and status lookups are claims that move the state forward; settlement files and bank statements are what prove it. The state never moves backwards except through a recorded reversal, and an unknown outcome is a state of its own that is resolved by lookup, never by resending.

  1. RecordedThe attempt and its idempotency key are stored before anything is sent.
  2. SentThe request left your system. No answer yet.
  3. Accepted or processingThe provider acknowledged it and is working on it.
  4. SucceededThe provider claims it completed. Still a claim.
  5. ProvenSettlement and bank evidence agree.
Side paths: Failed (a definite rejection), Unknown (no usable answer), Cancelled (before execution), and Reversed (paid, then returned).

Why webhooks cannot be the source of truth

Payment webhooks are usually delivered at least once, which means a callback can arrive late, twice, out of order, or not at all. They are also the easiest input to spoof if signatures are not checked. A design that sets the payout’s status to the payload of the latest webhook will, sooner or later:

  • move a succeeded payout back to processing when a delayed event arrives;
  • mark a payout failed because a stale error was redelivered after a successful retry by the provider;
  • leave a payout in processing forever because the final callback was lost;
  • show a payout as paid when the provider later leaves it out of the settlement file.

A webhook is one claim from one channel of one organisation. It should move the state machine forward when it is verified, new and consistent with the current state, and it should be recorded as evidence either way.

States and allowed transitions

These are the attempt states documented in the Flominzo documentation. Whatever names you choose, make the allowed transitions explicit and reject everything else.

StateCan move toTriggered by
RECORDEDSENT, CANCELLEDYour system sending the request, or a cancellation before sending.
SENTACCEPTED, PROCESSING, SUCCEEDED, FAILED, UNKNOWNThe provider’s response, or the lack of one.
ACCEPTEDPROCESSING, SUCCEEDED, FAILED, CANCELLEDVerified callbacks and status lookups.
PROCESSINGSUCCEEDED, FAILED, UNKNOWNVerified callbacks, lookups, or a window running out.
UNKNOWNACCEPTED, PROCESSING, SUCCEEDED, FAILEDA lookup, settlement evidence or a recorded human decision. Never a resend.
SUCCEEDEDREVERSEDA return or reversal, evidenced.
FAILED(terminal for this attempt)A new attempt, if any, is a separate record with the same key when the failure was retryable.
CANCELLED(terminal)Confirmed cancellation before execution.
REVERSED(terminal for the attempt)The reversal is reconciled and the payment reopened.

Two rules do most of the work. First, never move backwards: a late processing event cannot overwrite an evidenced success. Second, unknown is never terminal: it always has a path to resolution and an owner when that path runs out.

Handling events safely

  1. Verify, then record. Check signature, source and freshness; store the event as evidence whether or not it changes state.
  2. Deduplicate by event id. Apply each provider event once. A second delivery is stored as a duplicate and changes nothing.
  3. Order by the provider’s sequence, not arrival time. Where the provider gives a sequence number or event timestamp, use it to discard stale transitions.
  4. Apply transitions atomically. Use a conditional update on the current state, so two workers cannot apply conflicting transitions at the same moment.
  5. Raise, don’t guess. A verified event that contradicts the current state, such as a failure after an evidenced success, becomes an exception for a person rather than a silent overwrite.
// Simplified. One transition, applied only if it is allowed from the current state.
function applyEvent(attempt, event) {
  if (!verified(event) || seen(event.id)) return record(event)       // evidence, no change
  const next = transitionFor(attempt.state, event)                   // null if not allowed
  if (!next) return raiseException("CLAIM_MISMATCH", attempt, event) // contradiction
  return updateIf(attempt.id, attempt.state, next, event)            // atomic, conditional
}

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

Store events, derive the state

A status column that each webhook overwrites loses the history you need when something goes wrong. Store every input as an event and derive the current state from them.

FieldWhy
Provider event idDeduplicates redelivered webhooks.
Provider sequence or event timeOrders events that arrive out of order.
Received time and channelWebhook, lookup, file or bank statement: each has its own grade of evidence.
Verification resultWhether the signature, source and freshness checks passed.
Raw payload hashProves what arrived, and lets you re-parse it after a bug fix.
State before and afterShows exactly which event moved the payout, or why it did not.

The current state is then a projection of those events. You can rebuild it at any time, explain every transition, and replay a day after fixing a parser without losing what the provider actually sent.

Polling windows per rail

When a callback does not come, look the payout up. How soon and how often depends on the rail and the provider, so keep windows in configuration per provider, taken from its documentation and your agreement with it.

Rail typeWhen a final answer usually existsWhat to poll for
Instant account-to-account (such as Faster Payments, UPI and IMPS, RTP and FedNow, NPP)Usually soon after sending, but pending and deemed states exist on some rails.The rail reference and final status; later, any return.
Batch account-to-account (such as Bacs, ACH, EFT, NEFT, BECS)When the batch settles. Bacs runs on a three-working-day cycle; NEFT settles in half-hourly batches.Batch acceptance, item results, then returns that arrive days later.
Mobile money walletsOften quick, but operator callbacks can arrive late.The wallet transaction id and credit confirmation.
Push-to-cardNetwork approval is quick; settlement arrives in network and processor reports.Approval, then the settlement line.
Cross-border through a partnerCan take hours or days, depending on the partner and destination.The partner’s status and its settlement report.

A sensible pattern is a few lookups soon after sending, then longer intervals until the provider’s callback window closes, then a check of the next settlement file. When every window has passed without evidence, the payout becomes an exception with an owner.

Test the failure cases, not just the happy path

Most payout state machines are tested with a request that succeeds and a callback that arrives once. Production sends everything else. Before going live with a provider, run each of these cases against your integration:

  • Duplicate delivery: the same callback twice. The second changes nothing.
  • Out of order: “succeeded” arrives before “processing”. The state stays succeeded.
  • Lost final callback: no callback after acceptance. A lookup resolves it inside the window; otherwise an exception is raised.
  • Timeout, then success: the request times out but the provider processed it. The payout moves from unknown to succeeded through a lookup, and nothing is sent twice.
  • Failure after success: a failure event arrives after an evidenced success. The state does not move back; an exception is raised.
  • Return after success: the payout is returned days later. It moves to reversed and the payment reopens.
  • Forged callback: an unsigned or wrongly signed request. It is logged and ignored.

A small proxy that delays, duplicates and drops webhooks in a test environment is one of the cheapest ways to find these bugs before your recipients do.

Succeeded is not the end

A provider’s success status is a claim. The payout is proven when the provider’s settlement evidence and your bank statement agree with it. Keep the attempt state and the reconciliation state separate: an attempt can be SUCCEEDED while its reconciliation is still UNRECONCILED, and a later return moves the attempt to REVERSED and reopens the payment.

That is what Flominzo means by 100% reconciliation: every payment is matched or explained, with evidence: it ends either matched against independent evidence - the provider’s records, the settlement file and the bank statement - or as an open exception with an owner and a reason. None is silently assumed paid.

How Flominzo applies it

In Flominzo, every attempt is recorded before it is sent, every adapter classifies each provider result as success, retryable, non-retryable or unknown outcome, and every callback is verified before it is interpreted. Events are appended to the payment’s history rather than overwriting a status field, and exceptions such as a missing callback, a claim mismatch or a stale status are raised automatically when evidence contradicts the state or a window passes.

Questions

Should I poll or use webhooks?

Both. Webhooks give fast updates; lookups fill the gaps when a webhook is late or lost. Settlement and bank evidence then prove what the claims said.

What if a webhook says failed after the payout succeeded?

Do not overwrite the success. Record the event, raise a claim mismatch, and look the payout up. Some providers send a failure followed by a successful internal retry; only evidence resolves which is true.

How long should a payout stay unknown?

Only as long as its lookup and callback windows for that provider and rail. After that it becomes an exception with an owner, who decides with the evidence in front of them.

Can I retry a payout stuck in processing?

No. Processing means the provider has it. Look it up, wait for the window, and check settlement evidence. Sending it again risks paying twice.

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