Suppose a merchant has GHS 36,400 available and schedules a payout of exactly GHS 30,000. The obvious implementation sends GHS 30,000 to the bank or wallet and subtracts it from the balance.

That implementation works until something else wants the same money, a refund arrives during selection, the transfer request times out after reaching the provider, or the provider reports a failure without saying whether the sending account was debited.

Those cases expose the assumptions hidden inside “send 30,000 and subtract 30,000.” The approach works only while every fact around the transfer remains simple.

Our payout system therefore keeps four facts separate: balance transactions record money entering or leaving the merchant's Inttegro balance; a payout records the merchant's instruction to settle some of that money; a financial account identifies the destination bank account or wallet; and an external provider attempts the transfer prepared by Inttegro.

Those objects answer different questions: Which money is eligible? Which destination should receive it? Did we ask the provider to move it? Did the provider debit the sending side? Did the destination receive it? Treating them as one event would make the easy path shorter and every failure harder to explain.

Those questions impose three demands on the design: exact accounting before and after the transfer, recovery without repeated external side effects, and a normal payout that moves as quickly as the provider permits. Correctness, durability and speed have to survive together; improving one by giving up another merely relocates the failure.

A payout carries the merchant's instruction, its source allocations, the provider attempt and the final accounting result. The transfer occupies one stage in that sequence.

Selecting the money

POST /balances reports how the merchant's funds were classified at a particular cutoff. It neither reserves that amount for a future payout nor promises that every line behind it will remain eligible when execution begins.

POST /payouts/schedule calls the requested amount max_amount because it sets a ceiling: “settle no more than this much.” When execution begins, the payout selects eligible balance transactions for the destination's currency and records the amount it can actually claim. In this example, the eligible sources cover the request, so the ceiling and the final payout amount are both exactly GHS 30,000.

We admit a balance transaction only after it has reached its availability time, retained positive capacity after existing refund and payout allocations, matched the currency and satisfied its payout policy. The payout can therefore be lower than the balance the merchant saw earlier, but its source is true at the moment we claim it.

Selection proceeds from the oldest eligible money to the newest. Consider three balance transactions with GHS 12,450, GHS 8,750 and GHS 15,200 available for the GHS 30,000 payout:

SourceAvailableAllocatedLeft
OldestGHS 12,450GHS 12,450GHS 0
NextGHS 8,750GHS 8,750GHS 0
NewestGHS 15,200GHS 8,800GHS 6,400
PayoutGHS 30,000GHS 6,400

The third transaction contributes GHS 8,800 and retains GHS 6,400 for a later refund or payout. That distinction appears in POST /payouts/lookup: the source transaction keeps its original amount, while allocated_amount records the exact portion contributed to this payout.

The system now knows which money would fund the payout, but that knowledge does not protect the capacity. Until we record a claim, another payout or refund can still select it.

Claiming the selected money

Before any transfer request leaves Inttegro, we record each selected amount as an allocation against its source balance transaction. For the GHS 30,000 payout above, those allocations preserve the fact that GHS 12,450 came from the first payment, GHS 8,750 from the second and GHS 8,800 from the third. While the claims remain pending, every other operation sees only what is left. If the payout cannot proceed, we release the same claims without trying to reconstruct their provenance from one aggregate balance.

Execution introduces another race: several payout instructions may be scheduled for one merchant, but only one may select and send a given currency at a time. Two instructions can each be valid on their own and still overdraw the available balance when executed together. We serialize payout execution per merchant and currency to prevent that combination.

During selection, the execution lock keeps two selectors from running over the same currency at once. Once the allocations are durable, they preserve the money promised to this payout and the lock can be released before the provider call. A slow provider therefore cannot block every later operation in that currency. The claims survive beyond the process that created them; the broad concurrency gate does not.

How we use Temporal

A payout does not move from reservation to completion in one database write: we still have to validate the destination, create one transfer attempt, record that dispatch is about to begin, call the provider and apply the answer. Each step depends on the one before it. If a process disappears between them, starting again from the beginning could reserve the money twice or send the transfer twice. We need to resume from durable evidence without inserting delay into healthy execution.

We use Temporal to preserve that sequence across process failures and deployments. It records a workflow's decisions in an event history outside the worker running it. When that worker stops, another can replay the history, reconstruct the workflow's state and continue from the last completed step.

Every payout gets a deterministic workflow ID derived from the merchant and payout IDs. Temporal rejects another workflow with the same ID, so retrying the handoff cannot create a second execution. The workflow then runs the payout in this order:

Payout executionOne workflow · six durable steps
  1. 01Claim executionmerchant + currency gate
  2. 02Select fundssource allocations persisted
  3. 03Create attemptstable transfer identity
  4. 04Claim dispatchwritten before provider I/O
  5. 05Call providerbounded activity retry
  6. 06Record outcomecommit, release or processing

The happy path does not wait

Six durable steps can sound like six rounds of waiting. They are not. When a payout is due now, the scheduler hands it to Temporal and the workflow runs continuously through selection, reservation, attempt creation, provider dispatch and outcome recording. There is no timer between those steps.

If the currency is free and the provider returns a terminal answer, the payout can complete in that same workflow run. Its latency is then the time needed to pick up the job, make the required storage calls and receive the provider's response. This is the seconds path, not the minutes-or-hours path.

Delay enters the flow at three specific boundaries: before execution, the payout may be waiting for its scheduled time or for another payout to finish selecting from the same currency; during execution, a storage or network operation may fail and need another attempt; and after dispatch, the provider may still owe us a terminal status. Temporal persists the workflow at each boundary and resumes it when the next action is due; a payout that encounters none of those conditions continues immediately.

Selection runs as a child workflow that finds eligible balance transactions and persists the exact allocation from each source. Operations that touch the database or provider run as activities with explicit timeouts and bounded retry policies.

The order of “claim dispatch, then call” matters: imagine that the provider accepts the GHS 30,000 transfer and our worker dies before it can save the response. Temporal retries the activity. The retry reloads the already-dispatched attempt and skips the provider call, leaving the payout pending for reconciliation. We may lack the final answer, but we do not manufacture a second transfer while trying to recover the first.

Recovery after a worker stopsSame payout · same attempt
Persisted before provider I/OGHS 30,000 · attempt 01 · dispatch claimed
Worker AProvider call in flight
  1. Dispatch claim written
  2. Provider receives the request
  3. Process stops before saving the response
Durable evidence
Attempt
dispatched
Activity
incomplete
Outcome
unknown
Worker BSame activity retried
  1. Temporal replays the workflow
  2. Activity reloads attempt 01
  3. Dispatch claim prevents another provider call
  4. Payout remains processing for reconciliation

Temporal cannot make a database write or provider call execute exactly once. The activities still need conditional writes, stable attempt IDs and idempotent transitions. Instead, Temporal tells us which step completed, applies the retry policy for the next one and lets another worker resume after the original process has disappeared. If execution has to wait for a policy change or a busy currency, a durable timer holds the wait; Continue-As-New starts a fresh event history without changing the payout's workflow identity.

We divide the responsibility deliberately: Temporal preserves how the work should continue; the payout record and ledger preserve what happened to the money. When the provider gives a terminal answer immediately, the workflow can apply it before completing. When the answer is pending or unclear, the workflow leaves the payout processing after the safe handoff and a status job continues asking the provider for evidence.

The handoff changes what the workflow can know. Up to dispatch, it controls whether another step should run. Once the request has reached the provider, local execution history can tell us what we asked for, but only provider evidence can tell us what the external rail did.

After dispatch, evidence replaces control

Before dispatch, Inttegro can stop the payout without wondering whether money moved. After dispatch, we are observing an operation owned by another system. “Did our request finish?” no longer gets us far enough: we have to ask what the available evidence lets us conclude about the transfer.

An HTTP success may mean only that the provider accepted the instruction for processing; it does not prove that the destination received the money. A timeout proves even less: the provider may have accepted the request before the connection disappeared. Sending the request again would turn missing evidence into a possible duplicate transfer.

For that reason, we create a stable attempt before dispatch and keep the provider reference and safe conversation evidence attached to it. A reconciliation worker asks the provider for the current state of that attempt. A pending observation leaves the payout in flight. We apply a terminal observation only when it still belongs to the current attempt, so an old status job cannot finish a newer one.

A terminal response closes the payout in the same workflow run; a pending response lets execution finish its safe handoff and moves status checking to a background job. That division keeps provider waiting off the happy path while preserving the attempt we must reconcile.

The public status exposes the same branches: a payout returned by the scheduling endpoint begins as scheduled and becomes executing when work starts. A terminal provider answer can move it directly to succeeded or failed; a pending or unclear answer moves it to processing until reconciliation supplies the terminal evidence. canceled is possible only before dispatch. Merchants can read the current state through POST /payouts/lookup.

Once the provider reports a terminal status, we can close the operational attempt. The accounting may remain open. In particular, failed tells us that the transfer did not complete as intended; it does not, by itself, tell us whether the provider debited the sending side.

What failure means for the balance

We complete or release the allocations only after answering that debit question. Once a payout has crossed the provider boundary, the evidence can lead the accounting in three directions:

Succeeded

Commit the allocations and append the payout debit.

No debit

Release the allocations so another operation may use them.

Uncertain

Keep the allocations held until provider evidence resolves the debit.

When the provider confirms success, we complete the source allocations, record the payout's final amount and append one immutable payout debit to the ledger.

When the provider confirms that no debit occurred, we release the allocations and make their capacity available to later work. Canceling a scheduled payout leads to the same treatment because the transfer never started.

When the evidence remains inconclusive, we keep the money claimed. A provider may report that delivery failed after debiting its sending side, or a status lookup may end without establishing what happened. Releasing the allocations then would make the same money spendable inside Inttegro while the provider might still hold or have moved it.

We therefore keep the allocations held until provider evidence shows either that no debit occurred or that an earlier debit was reversed. The payout may already be terminal as an operation, while its source money remains unavailable as an accounting liability. The merchant waits longer for those funds in this ambiguous case. Releasing them sooner would improve apparent speed by inventing balance, so we do not take that shortcut.

The provider evidence now points toward one of three treatments: commit the claims, release them or continue holding them. We still have to carry that treatment through the payout, its allocations and the ledger without allowing those records to disagree.

Closing the accounting loop

To close the loop, we connect the merchant's instruction, the source money reserved for it, the provider's final evidence and the ledger entry recording money leaving. Updating only the payout's status would close the request while leaving the accounting unfinished.

RecordQuestion it answers
PayoutWhere was the money meant to go, how much was selected, and what is the current lifecycle state?
Source allocationsWhich balance transactions funded the payout, by exactly how much, and are those claims reserved, completed or released?
Provider evidenceWhat did the external rail report about the current attempt?
Payout debitWhat immutable ledger entry records the successful movement out of the merchant balance?
Operations logIn what order did selection, dispatch, observation and reconciliation happen?

The payment balance transactions explain where the GHS 30,000 came from. The payout debit records the successful settlement of that amount. We keep those roles separate and connect them through the payout instead of rewriting several old payment records until they resemble one new transfer.

That separation also makes an interrupted reconciliation repairable. If provider success has been persisted but a later accounting write fails, the next pass derives the same payout debit identifier, verifies any entry already present and completes only the missing links. The repair resumes from durable records and converges on one result instead of reopening the provider transfer or applying the ledger movement again.

The merchant sees a simpler version of the same chain: store the payout ID, poll POST /payouts/lookup until the payout is terminal, sum the returned allocated_amount values to explain its source composition, and compare the final amount with the deposit at the destination. The payout ID connects the records inside Inttegro; the bank or wallet statement provides confirming evidence outside it.

That procedure depends on retries returning to the same instruction, attempt and ledger movement. The next question is how each of those boundaries prevents a repeated request from creating repeated work.

Idempotency starts the retry story

Every payout-changing API request accepts an idempotency key. If a merchant schedules a payout and loses the response, repeating the request with the same key must return the original result rather than create a second payout.

That protects the public request boundary. It does not make the rest of the workflow exactly-once by magic. Workers retry. Provider status checks retry. A process can stop after one durable write and before the next. Every transition therefore has its own ownership and condition: the same attempt cannot terminalize twice, a stale attempt cannot overwrite the current one, a committed allocation cannot be released, and an existing payout debit must match the persisted payout before it is accepted.

That protection has a boundary: the request key deduplicates one API operation; stable attempt references protect provider work; and conditional writes and deterministic ledger identifiers protect internal transitions. Calling all of that “exactly once” would hide the boundaries we actually rely on.

Prior art

We did not invent payout objects, balance reservations, idempotency keys or reconciliation records. Public systems already show why each of those ideas exists. Rather than compare status names, look at where each system draws the line between instruction, transfer and accounting.

Public systemWhat it shares—and where it differs
Stripe PayoutsStripe exposes a payout lifecycle, a linked balance transaction and an important warning: some payouts that appear paid can later become failed. That reinforces the separation between a transfer's current status and final accounting evidence. Our payout response additionally exposes the exact source allocations that composed one payout.
Adyen payout lifecycleAdyen distinguishes authorisation, where funds are reserved, from booking, where funds are deducted, and records a later return as a new transaction. We draw a similar line between allocation, payout debit and any future reversal. The provider rail and our merchant ledger remain separate authorities.
PayPal PayoutsPayPal gives a batch its own lifecycle and uses a sender batch ID to reject duplicate payout requests for a defined period. It shows how durable intent can make retries safer. Our idempotency key protects the API instruction, while allocation and attempt identities protect the later stages independently.
Stripe idempotent requestsStripe stores the first result for an idempotency key and rejects reuse with different parameters. We use the same public idea. But a request key cannot resolve an ambiguous downstream transfer, so provider reconciliation still has to exist.

Read together, these systems expose the same difficulty: the instruction, the reservation or debit, the external lifecycle and the final evidence do not always arrive as one event. We preserve those boundaries because Inttegro must also explain which merchant funds composed the movement.

Why this approach?

The shortest implementation would keep one available-balance number, subtract the requested payout amount, call the provider and add the amount back when the call fails. It uses fewer records and fewer transitions. It also assumes a synchronous provider, one currency, no concurrent refunds and a process that never disappears between side effects.

Our system has to account for different conditions. A payout can be funded by many payments. Refunds and other payouts compete for the same capacity. Providers complete work asynchronously. A response can be lost after the side effect. Workers restart. Operators need to explain the result from durable records, not from whichever logs survived.

By this point, the three goals are attached to mechanisms rather than claims: source allocations and conditional transitions protect correctness; workflow history, stable identities and repairable reconciliation provide durability; and short-lived locks, immediate handoff and background status checks keep the healthy path fast.

ConstraintDecision
One payout, many sourcesAllocate exact amounts from individual balance transactions instead of subtracting an unexplained total from a snapshot.
Several consumers, one balanceReserve capacity before dispatch and serialize payout selection per merchant currency, so concurrent work cannot promise the same money twice.
The provider answers laterPersist an attempt, reconcile it asynchronously and treat missing or ambiguous evidence as unresolved rather than as a convenient failure.
Retries are normalGive the API request, provider attempt and accounting transition separate idempotency boundaries instead of assuming one key can protect the whole distributed workflow.
Accounting must explain itselfKeep source allocations, terminal provider evidence, an immutable payout debit and an ordered operational history connected by the payout ID.

This choice creates more states, more conditional writes, durable workflows, background reconciliation and repair paths. An uncertain provider outcome can keep merchant funds unavailable until evidence arrives. We accept that machinery because each part owns a named problem. A healthy payout moves without an intentional wait. A crashed worker resumes from durable evidence. A terminal result remains traceable to the money that funded it. The shorter alternative does not remove those problems; it hides them in one balance number and a retry loop.