All posts Product

Nobody Wants to Go First: The Escrow Flow

The buyer will not send the money first and the seller will not hand over the ticket first. In 2.El Bilet I broke that deadlock not with a single button but with an escrow flow built from states that wait on each other.

DT
Demir Taşdemir Mobile App & Web Developer
— min read

The first version of 2.El Bilet had a single button: “Buy”. The buyer tapped it, the seller got a notification, and everything after that came down to the good faith of two people who had never met. It did not work — because nobody wants to go first.

The problem was not a bug in the code. The buyer was saying “transfer the ticket first”, the seller was saying “send the money first”. Both were right and both were waiting. That is a deadlock, and a single button does not resolve it.

The deadlock where both sides are right

With a second-hand ticket the risk is symmetric. The moment the buyer sends the money they are holding nothing; the moment the seller transfers the ticket they cannot take it back. Each side knows the other could simply disappear.

I did not learn this at the design stage — I learned it from the messages that came in once the first version was live. Most of the questions were not technical: “I have paid, what happens if the other side never sends the ticket?” The app had no answer to that question.

The answer is called escrow: holding the money in the middle. But escrow is not a button, it is a flow. Hours, sometimes days, pass between the moment the payment is taken and the moment it is released to the seller, and throughout that window the app has to tell both sides clearly what is happening right now and whose turn it is.

A purchase is a process, not an event

In the first data model I represented the transaction with a single field: satildi. That field can only say two things — it happened or it did not. In real life, though, there is a pile of half-finished states in between: payment taken but ticket not transferred, ticket transferred but the buyer has not checked it yet, buyer opened a dispute.

My first reflex was to add more flags: odendiMi, teslimEdildiMi, onaylandiMi, iptalMi. Four booleans means sixteen combinations, and most of them are meaningless. Nothing stops a state like “cancelled but approved” from existing; you end up trying to enforce the rules by writing a separate if on every screen.

I deleted the flags and moved to a single durum field. Next to it I put a list: which state can move to which, and by whom. The rest of the app fell out of those two things.

Before writing any code I drew four columns on a sheet of paper: state, whose turn it is, the expected step, and what happens when the clock runs out. Filling in the fourth column is where I noticed the gaps in half the flow — because the rows with no answer come back later as support messages.

State Whose turn Expected step If the clock runs out
odeme_bekleniyor Buyer Completes the payment Transaction cancelled, ticket goes back on sale
odeme_alindi Seller Transfers the ticket, uploads the proof Cancellation and refund
bilet_iletildi Buyer Approves or opens a dispute Automatic approval
itiraz_acildi Me I review it and decide
tamamlandi, iptal_edildi, iade_edildi Closed

Once the table was filled in, the screen design came out almost by itself: every state is a screen state, and the “expected step” column is the single primary button on that screen.

The client cannot write the state

This is the most important rule. The app never writes to the durum field directly; it only makes a Cloud Functions call saying “I want to make this transition”. The server decides whether the transition is valid. In the Firestore rules that field is completely closed to client writes.

The allowed transitions live in one place. That way the answer to “can the seller mark it as delivered before the payment is taken” is not scattered across the code of three different screens — it sits in a single map.

// Cloud Functions — the allowed transitions, all in one file
const GECISLER = {
  odeme_bekleniyor: {
    odeme_alindi: ["odeme_saglayici"],
    iptal_edildi: ["alici", "zaman_asimi"]
  },
  odeme_alindi: {
    bilet_iletildi: ["satici"],
    iade_edildi:    ["satici", "zaman_asimi"]
  },
  bilet_iletildi: {
    tamamlandi:    ["alici", "zaman_asimi"],  // auto-approve if the buyer stays silent
    itiraz_acildi: ["alici"]
  },
  itiraz_acildi: {
    tamamlandi:  ["yonetici"],
    iade_edildi: ["yonetici"]
  }
};

function gecisGecerliMi(mevcut, hedef, aktor) {
  const izinliler = GECISLER[mevcut] && GECISLER[mevcut][hedef];
  return Boolean(izinliler && izinliler.includes(aktor));
}

The transition itself runs inside a transaction, and the client also sends along “here is the state I think I am currently in”. That small detail solves both the double tap and the stale screen left open in the background, in one move.

await db.runTransaction(async (t) => {
  const snap  = await t.get(islemRef);
  const islem = snap.data();

  if (islem.durum !== istek.beklenenDurum) {
    throw new Error("durum-degisti");     // double tap or a stale screen
  }
  if (!gecisGecerliMi(islem.durum, istek.hedefDurum, aktor)) {
    throw new Error("izinsiz-gecis");
  }

  t.update(islemRef, {
    durum:      istek.hedefDurum,
    siraKimde:  SIRA[istek.hedefDurum],
    sonTarih:   sonTarihHesapla(istek.hedefDurum),
    guncellendi: FieldValue.serverTimestamp()
  });

  // audit trail: one record per transition
  t.set(islemRef.collection("gecmis").doc(), {
    onceki: islem.durum,
    sonraki: istek.hedefDurum,
    aktor,
    zaman: FieldValue.serverTimestamp()
  });
});
Keep the history in a separate collection

Keeping a gecmis subcollection under the transaction document is a lifesaver when the “I never cancelled it” messages arrive later. Who made which transition, and when — visible at a glance. The write costs you one record, and what you get back is peace of mind.

The only question on screen: whose turn is it?

The same transaction document opens for two people, but the sentence those two people see has to be different. In the odeme_alindi state the seller's screen reads “Your turn: transfer the ticket”, while the buyer's screen reads “Your payment is held with us, waiting for the seller to transfer the ticket”.

For that I added a separate siraKimde field to the document. It could have been derived from the state, but computing it in one place and writing it down turned out to be safer than deriving it separately in two places — especially when building the notification copy.

The screen rule is simple: every state has exactly one primary button. The side whose turn it is not sees no action button at all, only a waiting message and the time remaining. Never leaving the user a moment where they have to wonder “what am I supposed to do now” was the most valuable design decision in this flow.

Every waiting state has an expiry date

Here is the sneakiest part of an escrow flow: if the other side does nothing, the flow freezes where it stands. The money is in the middle, the ticket is with the seller, and both sides are waiting. So every waiting state has a sonTarih field, and that field is calculated on the server at the moment the transition happens.

Counting the time down on the device does not work — the user closes the app, and the phone's clock can be wrong. Instead, a scheduled function periodically collects the expired transactions and calls the normal transition function for each one. The timeout is an actor too; it sits in the map as zaman_asimi.

const dolanlar = await db.collectionGroup("islemler")
  .where("durum", "in", ["odeme_bekleniyor", "odeme_alindi", "bilet_iletildi"])
  .where("sonTarih", "<=", Timestamp.now())
  .limit(200)
  .get();

The durations differ per state. While waiting for payment it has to be short, because the ticket is pulled from sale for that whole window. A longer window makes sense for the seller to transfer the ticket, and the same goes for the buyer to check it. As the deadline approaches I send a reminder notification; the notification has to be triggered by the state change itself, not from somewhere off to the side.

Automatic approval is a decision, not an accident

What happens if the buyer receives the ticket and then never touches the app again? Waiting forever punishes the seller. I chose to move to automatic approval when the clock runs out, but I tell the user that up front, in plain words, on the waiting screen. Nobody has agreed to a rule that was never stated.

Cancellation and disputes are transitions too

In the first version I thought of cancellation as a separate code path: a button and a delete. That was wrong. Cancellation is a transition like any other; who can cancel, and from which state, lives in the same map.

This needs to be pinned to a clear rule: the buyer can cancel while the seller has not transferred the ticket yet; once the seller has transferred it, the buyer has no cancel button — they have a dispute instead. When a dispute is opened the transaction does not go to a terminal state, it lands in front of me and the decision is handed over to a human.

The existence of the dispute state is what saves the flow. Without it, every disagreement automatically rules against either the buyer or the seller. Putting one gate that requires a human decision inside the flow is exactly what lets you automate everything else.

Holding the money is not a technical question

This post is about the flow itself. Where the payment sits and how long it can sit there depends on the framework your payment provider allows and on the regulations. Learn that framework before you design the flow; building the state machine around it from the start is far easier than trying to squeeze it in afterwards.

Small details with a high price tag

  • The same request arriving twice. On a weak connection the user taps the button twice. Putting the expected state into the request means the second request is rejected silently.
  • Triggering the notification from the transition. If you write the notification underneath the button, nobody hears about the transitions that come from a timeout. The notification has to go out from one place that listens to the state change.
  • Locking the terminal states. tamamlandi, iptal_edildi and iade_edildi are the states with no exit in the map. No row in the map means no transition; you do not have to write a separate check for it either.
  • Not mixing state names with interface copy. The field value stays fixed and technical; the sentence the user sees comes from a separate translation table. Changing the copy does not break the data model.

The lesson that stuck

After building this flow I realized that what I was really doing was not a payment integration — it was breaking a lack of trust down into small steps. Nobody wants to send money up front to a stranger; but when they read the sentence “your payment is held with us, the seller has one day to transfer the ticket, and if they do not you get refunded”, they can take a step.

The same problem shows up in every product where two sides take turns. Building states that wait on each other instead of a single button does not make the code longer either — on the contrary, it collapses a scattered pile of if statements into one map. When I start a new marketplace flow now, the first thing I do is not sketch screens, it is fill in that four-column table.

  • Product
  • State Machine
  • Marketplace
  • Firebase
  • Payment Flow
Share: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobile App & Web Developer

I have been building software since 2018. I have published 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.

Building a similar flow?

If you are designing a product where two sides take turns, let's map out the states together — drop me a line.