Escrow is a state machine with money in it

Escrow fails when money can sit in two states at once. Model every state, transition, retry and ledger entry clearly.

Most escrow systems begin in a simple way. There is a balance column, a set of conditions, and a job somewhere that moves money out when a flag says it can be released. Money comes in. Work happens. The flag changes. The job runs.

That can work for months. Then it fails, and the failure is usually in the same place. A payment is in two states at once, or in none. The system cannot say clearly whether money has only been authorised, has actually been captured, is being held, is ready to release, has been sent, or has landed.

Escrow is a finite state machine, meaning every payment is always in one defined condition and can move only through allowed paths. Treating it any other way is the root of nearly every serious defect in this kind of system. Treating it properly is the difference between a platform that can compute a risk-based release and one that applies a uniform fourteen-day hold because it does not confidently know what is going on.

Escrow needs clear states from the start

In our design, there are nine states. Fewer is usually a sign that different things have been collapsed together. That is where bugs start, because the business thinks one thing has happened and the software records another.

These are the nine states we use:

  • Draft. An agreement exists, no money has moved. Both parties can still change terms.
  • Authorised. The client’s payment method has been checked and an amount reserved, but not taken.
  • Captured. Funds have actually moved to the platform’s account. This is distinct from authorised and conflating the two is the single most common error we see.
  • Held. Funds are with the platform, work is in progress, no release condition met.
  • Submitted. The freelancer has delivered. A clock is now running on the client.
  • Releasable. The condition for release has been met, whether by acceptance, timer expiry or adjudication.
  • Released. A payout has been initiated. This is different from arrived.
  • Settled. The payout has actually landed. This is the only state where the money is genuinely the freelancer’s.
  • Refunded. Funds returned to the client, wholly or partly.

There is also one thing that looks like a state but should be a flag: disputed. It can attach to Submitted, Releasable or Held and suspends automatic transitions.

The important separations stop real failures

Three of those separations do most of the work. Each one exists because collapsing it caused a real incident somewhere. These are small words in a database, but they carry large business consequences.

  • Authorised versus captured. An authorisation can expire or be declined at capture. A system that treats them as one will show a client that they have paid and a freelancer that funds are held, when in fact no money exists anywhere. Everyone then works for a week on a job that was never funded.
  • Released versus settled. A payout can be initiated and then fail: closed account, wrong details, a compliance hold at the receiving bank. If the system marks it released and moves on, the freelancer is told they have been paid and has not been. Reconciliation will miss it because from the platform’s side the transaction is complete.
  • Releasable versus released. The condition being met and the money actually moving are different events, often separated by hours. Without that distinction there is no way to answer how much is owed but not yet paid, which is the number an auditor asks for first.

Every move needs an actor and a rule

Every transition has three properties: the states it connects, the actors permitted to trigger it, and whether it is reversible. Writing this out as a table before implementation is the single most useful hour in the project.

The permitted-actor part is where security lives. A client may move Submitted to Releasable by accepting. A freelancer may not. A timer may do it on the client’s behalf after a defined window. An adjudicator may move Disputed to Releasable or Refunded. Nobody may move Settled anywhere, because settled is terminal.

The reversibility question decides your entire refund story. Captured to Refunded is reversible in the sense that the money can go back. Settled to Refunded is not reversible, because the funds have left. Any product decision that assumes you can claw back a settled payout is a decision to chase individuals for money, which is a business you did not intend to be in.

The forbidden moves matter as much as the allowed ones

The transitions that do not exist are equally important, and they are usually undocumented. Held cannot go directly to Settled. It must pass through Releasable so there is a record of why. Draft cannot go to Captured without Authorised. Refunded cannot go anywhere.

Write the forbidden transitions down explicitly and enforce them in code rather than by convention. That is what stops the state machine degrading over time as features are added by people who were not there for the original design.

Retries mean the same event can arrive more than once

Payment providers retry. Webhooks, which are callbacks from the provider to your system, arrive more than once, sometimes out of order, occasionally days late. Your own retry logic fires too. None of that is exceptional. All of it is Tuesday.

Every transition carries an idempotency key, which is a repeated-event identifier, derived from the event that caused it rather than generated per attempt. Processing the same event twice must produce the same state and exactly one ledger entry. We test this by replaying entire days of webhook traffic in staging and asserting the resulting ledger is byte-identical.

The failure this prevents is the worst one in the category: a duplicated release, where a freelancer is paid twice and the platform absorbs the difference. It is not rare in systems that were not built for it.

The ledger and the state machine do different jobs

This distinction is worth being pedantic about. The state machine records what condition an engagement is in. The ledger records what money moved and when. They are related, and they must agree, but they are different records. A system that derives one from the other will eventually disagree with itself.

The ledger is append-only. Nothing is ever updated or deleted; a correction is a new entry that references the original. That property is what lets you answer, months later, what the position was on a given date. Auditors and regulators ask that question routinely. It is impossible to answer if rows have been mutated.

Time handling causes more bugs than money handling

More escrow bugs come from time handling than from money handling. That surprises people until they see how many escrow rules depend on windows, timers, deadlines and local time.

Store instants in UTC, always. Render in the viewer’s timezone, always. Never compute a deadline by adding days to a local date, because two of those days a year are not twenty-four hours long in a timezone that observes daylight saving. Your acceptance window will be wrong for exactly the people it affects.

Define whether a window is business days or calendar days. If it is business days, define whose calendar. A five-business-day acceptance window means different things to a client in Karachi and a freelancer in Denver, and somebody will notice at the worst moment.

Make every timer’s deadline visible to both parties as an absolute time in their own zone, rather than as a duration. Duration is ambiguous; a timestamp is not.

Daily automated reconciliation keeps small mismatches small

The platform’s view of what it holds must be compared against the payment provider’s view, every day, automatically, with an alert on any discrepancy.

Manual monthly reconciliation is how small differences become large ones. By the time a monthly process finds a mismatch, the transactions involved are weeks old and the people who could explain them have moved on. Daily reconciliation finds a problem while it is one transaction rather than four hundred.

The check is simple: sum of funds in Captured, Held, Submitted and Releasable states should equal the provider’s balance, less anything in flight. Any drift is investigated the same day.

These failures come from early design decisions

The abstract case for rigour is unpersuasive until you have seen the concrete failures. These are drawn from our own build and from client systems we were brought in to fix.

  • The double release. A webhook arrived twice, three seconds apart, from a provider retrying on a slow response. Both were processed. The freelancer received two payouts. The platform found out at month end. Cause: no idempotency key on the release transition. Cost: the duplicated amount, unrecoverable, plus a fortnight of reconciliation.
  • The phantom hold. An authorisation expired after seven days without being captured, but the engagement was already showing as funded. Work continued for two weeks against money that did not exist. Cause: authorised and captured collapsed into one state. Cost: a full refund of nothing, because there was nothing, and a client relationship.
  • The daylight saving deadline. A 72-hour acceptance window computed by adding three to a local date. On the clock change it expired an hour early, auto-accepting work a client had been about to reject. Cause: date arithmetic in local time. Cost: one adjudication and a rule change.
  • The silent payout failure. A batch of payouts was initiated. Nine failed at the receiving bank. The system marked all of them released and never checked settlement. Freelancers were told they had been paid. Cause: no distinction between released and settled. Cost: three weeks before anyone noticed, from a support message rather than a monitor.
  • The mutable ledger. A correction was applied by updating an existing row. Six months later an auditor asked for the position on a specific date and it could not be produced, because history had been overwritten. Cause: the ledger was a table rather than an append-only log. Cost: a qualified finding and a migration.

Every one of these is a design decision made early and cheaply, and paid for later at considerable expense.

Ordinary unit tests are only the starting point

Ordinary unit tests are necessary and nowhere near sufficient here. Three additional techniques do most of the work. They test the paths the system should allow, the paths it must reject, and the payment events that arrive in the wrong order.

  • Exhaustive transition testing. For every pair of states, assert either that the transition is permitted for a specific actor, or that it is rejected. Nine states means eighty-one pairs. Most should be rejections, and the rejections are the valuable half, because they are what stops a future feature quietly opening an illegal path.
  • Webhook replay. Capture a full day of real provider callbacks in staging, then replay them shuffled, duplicated and delayed. The resulting ledger must be identical to the ordered run. This finds ordering assumptions nobody knew they had made.
  • Chaos on the money path. Deliberately fail captures, fail payouts, time out mid-transition and kill the process between the state write and the ledger write. That last one is the interesting case: it should be impossible, because both should be in the same transaction, and the test proves whether that is actually true.

A correct model lets the business answer harder questions

The reason to do all this properly is not elegance. A correctly modelled escrow can answer questions a balance column cannot.

It can compute a release period from actual risk, because it knows exactly what condition every payment is in. It can produce a defensible audit trail without anyone assembling one. It can support partial releases against milestones without ambiguity. And it can run at a cost low enough that a marketplace does not need to charge a percentage of every transaction to fund it.

That last one is the commercial point. The engineering described here is what makes zero commission arithmetically possible, which is why we spent the time on it rather than shipping a balance column and moving on to features.

The full build, including the reconciliation design, is in the Open Lance case study. The same state-modelling discipline shows up in our financial services work, where the consequences of getting it wrong are larger still.

Written by Brilliant Systems

Our engineers write these between projects. If something here is relevant to a decision you are making, we are happy to talk it through without it becoming a pitch.

Certified, partnered and awarded