A marketplace escrow system holds the buyer's money on the platform until the order is complete, then releases it to the seller. Until that release, the seller cannot withdraw it and the buyer cannot spend it again.
If you only increment a seller_balance column when the buyer pays, you lose that hold period. You cannot tell who owns the money right now, what is locked in escrow, or what to reverse if the buyer disputes. Marketplaces need a ledger for that, not a generic debit-minus, credit-plus table.
You’ll want your system to track:
- Who owns what and at what point in time
- How money flows during deposits, purchases, withdrawals, escrow, refunds, chargebacks, etc.
- Event-level accuracy per transaction, rather than just end of day balances.
- Sync between your records and your payment processors, application logic, third-party analytics tools, etc.
This guide shows you how to model that escrow on Blnk Core: buyer and seller balances, inflight holds, commits, voids, and seller payouts.
What is a marketplace escrow system?
The platform sits between buyer and seller. The buyer pays first. The platform holds those funds. The seller is paid only after a condition is met, such as confirmed delivery. If the condition fails, the hold goes back to the buyer.
Your ledger needs three states per purchase: funds available on the buyer wallet, funds held in escrow (inflight: moved off the buyer, not yet spendable by the seller), and funds available to the seller after commit.
Segregating buyers and sellers is a ledger design choice: each party gets a balance, grouped under ledgers. For the folder layout, see Designing your ledger architecture with Blnk.
Setting up your infrastructure
First things first, start with a ledger.
Before you start to think about money workflows, payment logic, etc., you need to think about the infrastructure that’ll power them. For this guide, we’ll use our open-source ledger, Blnk Core, as our starting point.
As a marketplace, there are always three first-class entities:
- Buyer: Users making a purchase
- Seller: Users offering goods & services in exchange for money
- Organization (you): The platform making it all possible.
In your ledger (Blnk), you start by replicating this structure via ledger folders. Here, we’ll create two ledgers:
- General Ledger (default): For the organization (you).
- Customers Ledger: For the buyers and sellers. A customer can either be a buyer, seller, or both.
To create a ledger:
const response = await blnk.Ledgers.create({
name: 'Customers Ledger',
}); ledger, resp, err := client.Ledger.Create(blnkgo.CreateLedgerRequest{
Name: "Customers Ledger",
}) response = blnk.ledgers.create({
"name": "Customers Ledger",
}) ApiResponse<JsonNode> response = blnk.ledgers().create(
CreateLedger.create()
.name("Customers Ledger")); You could apply this ledger design to larger, more complex products, e.g. a global marketplace like Fiverr could create customer ledgers per currency/country. A product like Fiverr could have Customers USD Ledger, Customers GBP Ledger, and Customers JPY Ledger for customers transacting in USD, GBP, and JPY respectively.
Tracking money owned with balances
Next, we need to solve for who owns what. In Blnk, ownership lives on balances, not in a pooled number on the user row.
In real life, funds are not truly stored per user per vault somewhere. They’re typically pooled together into a kind of operational fund. Ownership is represented digitally, and this is where errors creep up on most developers.
So using our created ledger, we’ll create a balance for each user.
const response = await blnk.LedgerBalances.create({
currency: 'USD',
ledger_id: 'customers-ledger-id',
}); balance, resp, err := client.LedgerBalance.Create(blnkgo.CreateLedgerBalanceRequest{
Currency: "USD",
LedgerID: "customers-ledger-id",
}) response = blnk.ledger_balances.create({
"currency": "USD",
"ledger_id": "customers-ledger-id",
}) ApiResponse<JsonNode> response = blnk.ledgerBalances().create(
CreateLedgerBalance.create()
.currency("USD")
.ledgerId("customers-ledger-id")); Here’s what happens:
- A balance is created for the user with a
balance_id. - This balance is grouped as being a part of the customers ledger. This means when you see a balance with the customers ledger id, it tells you that this balance belongs to a customer.
- The balance is assigned a currency (by you). You could give it any currency you want.
Now, you have your ledger all set up and ready for transactions.
Don’t worry about your organization balances yet, we’ll come back to that when we start recording transactions.
Implementing transaction workflows
In Blnk, transactions happen between balances. When User A sends money to User B, what happens in the ledger is a transaction from User A’s balance to User B’s balance.
However, transactions are not always that simple. You have a lot of things you want to solve for per transaction and your ledger needs to be able to work with it.
For this guide, we'll work on 3 common workflows:
- Deposits
- Purchases between a buyer and a seller
- Withdrawals & settlements
Deposits: fund the buyer wallet
In some marketplaces, buyers fund a wallet before they buy. In others, they pay from a card or bank account at checkout. This example uses a funded wallet. For a processor-backed funding path, see how to build a wallet with Stripe and Blnk.
For this example, we’ll select the former scenario.
Add money to the buyer's balance. Blnk is double-entry: if you add money somewhere, it has to be deducted elsewhere.
In this case, the “deduction” is happening from outside our marketplace. To represent this, we’ll create an internal balance (@World) belonging to our organization to represent money coming in from outside.
const response = await blnk.Transactions.create({
amount: 1200,
precision: 100,
currency: 'USD',
source: '@World',
destination: 'buyer-balance-id',
reference: 'unique-reference',
description: 'Wallet deposit',
allow_overdraft: true,
}); transaction, resp, err := client.Transaction.Create(
blnkgo.CreateTransactionRequest{
ParentTransaction: blnkgo.ParentTransaction{
Amount: 1200,
Precision: 100,
Currency: "USD",
Source: "@World",
Destination: "buyer-balance-id",
Reference: "unique-reference",
Description: "Wallet deposit",
},
AllowOverdraft: true,
},
) response = blnk.transactions.create({
"amount": 1200,
"precision": 100,
"currency": "USD",
"source": "@World",
"destination": "buyer-balance-id",
"reference": "unique-reference",
"description": "Wallet deposit",
"allow_overdraft": True,
}) ApiResponse<JsonNode> response = blnk.transactions().create(
CreateTransactions.create()
.amount(1200)
.precision(100)
.currency("USD")
.source("@World")
.destination("buyer-balance-id")
.reference("unique-reference")
.description("Wallet deposit")
.allowOverdraft(true)); Here’s what happens:
- The buyer’s balance increases by the amount, e.g. $1,200.00 USD.
- You have a clear idea of where that amount came from. You can use metadata to add even more context like bank name, payment type, external reference, etc.
Escrow purchases: hold funds, then commit
Purchases can be prepaid, postpaid, or escrow. Prepaid moves money to the seller immediately. Postpaid bills later. Escrow holds the buyer's funds until both sides complete the order, which is the model most two-sided marketplaces need.
For an escrow model, here's a simplified workflow:
- Buyer initiates payment.
- Platform holds the transaction until purchase conditions are met (e.g. buyer confirms receipt).
- Seller sees the incoming payment, but can’t access it yet. Buyer can’t spend any funds held in escrow either.
We’ll create another transaction, but we’ll specify that we want it to be inflight. This tells the ledger that a transaction has happened but it shouldn’t be finalized yet.
const response = await blnk.Transactions.create({
amount: 500,
precision: 100,
currency: 'USD',
source: 'buyer-balance-id',
destination: 'seller-balance-id',
reference: 'unique-reference',
description: 'Payment for design proposal',
inflight: true,
}); transaction, resp, err := client.Transaction.Create(
blnkgo.CreateTransactionRequest{
ParentTransaction: blnkgo.ParentTransaction{
Amount: 500,
Precision: 100,
Currency: "USD",
Source: "buyer-balance-id",
Destination: "seller-balance-id",
Reference: "unique-reference",
Description: "Payment for design proposal",
},
Inflight: true,
},
) response = blnk.transactions.create({
"amount": 500,
"precision": 100,
"currency": "USD",
"source": "buyer-balance-id",
"destination": "seller-balance-id",
"reference": "unique-reference",
"description": "Payment for design proposal",
"inflight": True,
}) ApiResponse<JsonNode> response = blnk.transactions().create(
CreateTransactions.create()
.amount(500)
.precision(100)
.currency("USD")
.source("buyer-balance-id")
.destination("seller-balance-id")
.reference("unique-reference")
.description("Payment for design proposal")
.inflight(true)); Here’s what happens:
- You can see the total balance being held inflight for the buyer and seller.
- You can see the inflight state of the transaction in your transactions table, and you can show that in your application.
Let’s assume the conditions for finalizing the transaction are:
- Seller confirms delivery.
- Buyer confirms receipt and all is good to go.
Your code may look something like this:
async function finalizePurchase(transactionId: string, buyerConfirmed: boolean, sellerConfirmed: boolean) {
if (buyerConfirmed && sellerConfirmed) {
await blnk.Transactions.updateStatus(transactionId, { status: 'commit' });
} else {
await blnk.Transactions.updateStatus(transactionId, { status: 'void' });
}
} func finalizePurchase(transactionID string, buyerConfirmed bool, sellerConfirmed bool) error {
status := blnkgo.InflightStatusVoid
if buyerConfirmed && sellerConfirmed {
status = blnkgo.InflightStatusCommit
}
_, _, err := client.Transaction.Update(
transactionID,
blnkgo.UpdateStatus{Status: status},
)
return err
} def finalize_purchase(transaction_id, buyer_confirmed, seller_confirmed):
if buyer_confirmed and seller_confirmed:
blnk.transactions.update_status(transaction_id, {"status": "commit"})
else:
blnk.transactions.update_status(transaction_id, {"status": "void"}) void finalizePurchase(String transactionId, boolean buyerConfirmed, boolean sellerConfirmed) {
String status = buyerConfirmed && sellerConfirmed ? "commit" : "void";
blnk.transactions().updateStatus(
transactionId,
UpdateTransactionStatus.create().status(status));
} This logic says that:
- Commit the transaction only after both buyer and seller confirm that everything is complete. This ensures you only settle the seller when the buyer confirms receipt of the package.
- If confirmation is missing from either party or a dispute is raised, void the transaction and release the funds back to the buyer.
To commit the funds in your ledger:
const response = await blnk.Transactions.updateStatus(
'{transaction_id}',
{
status: 'commit',
},
); transaction, resp, err := client.Transaction.Update(
"{transaction_id}",
blnkgo.UpdateStatus{
Status: blnkgo.InflightStatusCommit,
},
) response = blnk.transactions.update_status(
"{transaction_id}",
{
"status": "commit",
},
) ApiResponse<JsonNode> response = blnk.transactions().updateStatus(
"{transaction_id}",
UpdateTransactionStatus.create()
.status("commit"));
What happens:
- The funds held in inflight is finally deducted from the buyer and added to the seller’s balance.
- Since it’s impossible for the buyer to access those held funds, you can always be sure that the transaction will always go through. Blnk handles insufficient fund checks for you.
Seller settlements and withdrawals
Next, users need to be able to withdraw their money. Some products do this automatically once the seller gets paid, while others allow sellers to hold money in their wallet and withdraw any time they want to.
The implementation for both workflows are the same. You simply reverse the deposits money movement. This time, money is deducted from the user balance and added to “@World.”
const response = await blnk.Transactions.create({
amount: 400,
precision: 100,
currency: 'USD',
source: 'seller-balance-id',
destination: '@World',
reference: 'unique-reference',
description: 'Settlement/withdrawals',
inflight: true,
}); transaction, resp, err := client.Transaction.Create(
blnkgo.CreateTransactionRequest{
ParentTransaction: blnkgo.ParentTransaction{
Amount: 400,
Precision: 100,
Currency: "USD",
Source: "seller-balance-id",
Destination: "@World",
Reference: "unique-reference",
Description: "Settlement/withdrawals",
},
Inflight: true,
},
) response = blnk.transactions.create({
"amount": 400,
"precision": 100,
"currency": "USD",
"source": "seller-balance-id",
"destination": "@World",
"reference": "unique-reference",
"description": "Settlement/withdrawals",
"inflight": True,
}) ApiResponse<JsonNode> response = blnk.transactions().create(
CreateTransactions.create()
.amount(400)
.precision(100)
.currency("USD")
.source("seller-balance-id")
.destination("@World")
.reference("unique-reference")
.description("Settlement/withdrawals")
.inflight(true)); Remember, “@World” is how we represent “outside” in our ledger, i.e. for withdrawals, we’re sending money outside of our product.
What else can you build?
Like you’ve seen, your money movement cannot be solved by just setting up simple accounting tables in your database. If you hope to grow your business or add creative features that affect how users pay in your app, an expert ledger is a no-brainer.
So what else can your marketplace ledger (Blnk) have:
- Overdrafts: Offer and manage instant settlements to sellers based on their pending receivables.
- Refunds: reverse a committed purchase with a full audit trail when a dispute or return lands.
- Split payments and fees: take platform commission on the same purchase. Use multiple destinations so the seller, your revenue, and processor fees land on separate balances.
- Delayed settlements and payouts: Track third-party payment processing in your ledger, e.g. Use inflight to model ACH settlements and only commit once the payout is confirmed.
Install Blnk Core and run this escrow flow locally. If you want the same ledger without operating the database, start a Blnk Cloud sandbox.
Marketplace escrow FAQ
How do marketplaces segregate funds between buyers and sellers?
Give each buyer and seller their own balance, grouped under a Customers ledger (or one ledger per currency). The platform's revenue and contra accounts live as internal balances on the General Ledger. Bank money can be pooled; ownership is the balance record.
How do I keep funds in escrow until both parties complete a transaction?
Create the purchase with inflight: true. The amount leaves the buyer's available balance and sits as inflight on both sides. Commit when buyer and seller confirm. Void if someone disputes. The buyer cannot spend held funds, so the commit cannot fail for insufficient funds.
Is this an escrow payment platform or a ledger?
Blnk is the ledger: the source of truth for who owns what. Your payment processor still moves cash. Record processor events against the same balances, then reconcile processor payouts so the ledger and the bank do not drift.