Most marketplaces need sellers. Without them, there is nothing to sell, and buyers have no reason to sign up. A seller stays when they trust you with their business and their growth. A lot of that trust shows up in inventory: what you list, what you promise a buyer, and whether they can actually fulfill it.
Get the stock wrong and their reputation takes the hit. Revenue goes down, growth stalls, and they leave or switch to another platform.
For some developers, v1 is usually an items table with a qty column. Checkout subtracts, a return adds. Maybe later, you split the quantity field into available and reserved, but that only tells you what the system knows right now. When a seller argues with the number or disputes an order, you open the row, see qty = 31, and cannot walk them through what happened or how you arrived at 31.
In this article, you’ll learn how to correctly track and manage inventory using a double-entry ledger: one balance per state for each item, every change as a move between those states, and a snapshot when a seller asks what happened.
Setting up your ledger design
If you’re using an items table, it’ll look something like this:
async function reduceAvailableStockOnSale(sellerId, sku, qtySold) {
await db.query(
`
UPDATE items
SET qty = qty - $1
WHERE seller_id = $2
AND sku = $3
AND status = 'available'
`,
[qtySold, sellerId, sku],
);
}
await reduceAvailableStockOnSale('sel_9f2a', 'IP16-BLK-128', 3); The problem is that most inventory units (SKU) are not all in one state. Of the same SKU you can have some available, some pending delivery, and some returned but not yet back on the shelf. One items table cannot hold all that. The usual fix is adding another table per state or keeping audit logs, and the query that fetches where the stock is gets harder every time your product expands.
This is where a ledger becomes necessary. It allows you to track each state per SKU per seller. For example, if an SKU had 30 available and sold 5 to different buyers, a ledger records each sale as a transfer from the SKU AVAILABLE balance to its SOLD balance.
In Blnk that looks like this:
Use a ledger to group states together
A ledger is a group of balances. Create one for each state in your system: available, sold, in transit, delivered, and returned. With this design, reading the
availableledger gives you the available stock across all SKUs in your product.Requestconst availableLedger = await blnk.Ledgers.create({ name: 'SKUs Available', }); const soldLedger = await blnk.Ledgers.create({ name: 'SKUs Sold', }); const inTransitLedger = await blnk.Ledgers.create({ name: 'SKUs In-transit', }); const deliveredLedger = await blnk.Ledgers.create({ name: 'SKUs Delivered', }); const returnedLedger = await blnk.Ledgers.create({ name: 'SKUs Returned', });200 OK{ "ledger_id": "ldg_82da565c-b912-45dd-a148-9e7ce8f09000", "name": "SKUs Available", "created_at": "2026-03-12T09:14:18.558281542Z" }Create an identity per SKU
Use identities to register SKUs in the ledger. An SKU refers to the version of an item you count on its own, e.g. an iPhone 16 Black and iPhone 16 White are two SKUs, so they get two identities, and are tracked as separate inventory.
Requestconst sku = await blnk.Identity.create({ identity_type: 'organization', organization_name: 'iPhone 16 Black 128GB', category: 'electronics', meta_data: { seller_id: 'sel_9f2a', item_code: 'IP16', sku: 'IP16-BLK-128', color: 'black', storage: '128GB', seller_details: { first_name: 'Kemi', last_name: 'Adeyemi', }, }, });200 OK{ "identity_id": "idt_5cd5a916-b755-4cf0-8991-99ec040df268", "identity_type": "organization", "organization_name": "iPhone 16 Black 128GB", "category": "electronics", "created_at": "2026-03-12T09:14:22.118244338Z", "meta_data": { "seller_id": "sel_9f2a", "item_code": "IP16", "sku": "IP16-BLK-128", "color": "black", "storage": "128GB", "seller_details": { "first_name": "Kemi", "last_name": "Adeyemi" } } }Field What it does seller_id Scopes this SKU to the seller or store that listed it. Details managed in a separate table. item_code The parent item. Pull every iPhone 16 regardless of color, seller, etc. sku This version only. White 128GB would be IP16-WHT-128. Use balances to track each state
Each state becomes a balance on the same SKU identity. This iPhone 16 Black 128GB becomes one identity with five balances in their respective ledgers (step 1): available, sold, in transit, delivered, and returned.
When units move from one state to another, you record a movement between those balances.
Requestconst available = await blnk.LedgerBalances.create({ ledger_id: availableLedger.data.ledger_id, identity_id: sku.data.identity_id, currency: 'UNIT', }); const sold = await blnk.LedgerBalances.create({ ledger_id: soldLedger.data.ledger_id, identity_id: sku.data.identity_id, currency: 'UNIT', }); const inTransit = await blnk.LedgerBalances.create({ ledger_id: inTransitLedger.data.ledger_id, identity_id: sku.data.identity_id, currency: 'UNIT', }); const delivered = await blnk.LedgerBalances.create({ ledger_id: deliveredLedger.data.ledger_id, identity_id: sku.data.identity_id, currency: 'UNIT', }); const returned = await blnk.LedgerBalances.create({ ledger_id: returnedLedger.data.ledger_id, identity_id: sku.data.identity_id, currency: 'UNIT', });200 OK{ "balance_id": "bln_07bd1f8c-80e4-4109-9df7-3a98e2352b71", "ledger_id": "ldg_82da565c-b912-45dd-a148-9e7ce8f09000", "identity_id": "idt_5cd5a916-b755-4cf0-8991-99ec040df268", "currency": "UNIT", "balance": 0, "created_at": "2026-03-12T09:14:26.238244338Z" }Field What it does ledger_id Which group this balance sits in. identity_id Which SKU does this belong to.
Record movement between states
With your ledger set up, you can start to manage your inventory as a movement between states. Let's use a sale as our example.
Let's say you sell 2 units of the iPhone 16 in one checkout. The full journey goes from available to sold, in transit, and finally delivered. Between each state is a bit of delay and required conditions that must be met.
Record the three movements as a bulk
With Blnk, we'll record the group of movements as a single bulk request:
Requestconst orderId = 'ord_4412'; const sale = await blnk.Transactions.createBulk({ atomic: true, inflight: true, transactions: [ { amount: 2, precision: 1, currency: 'UNIT', reference: `${orderId}_sold`, source: available.data.balance_id, destination: sold.data.balance_id, description: 'Checkout paid', meta_data: { order_id: orderId, sku: 'IP16-BLK-128', }, }, { amount: 2, precision: 1, currency: 'UNIT', reference: `${orderId}_in_transit`, source: sold.data.balance_id, destination: inTransit.data.balance_id, allow_overdraft: true, description: 'Picked up for delivery', meta_data: { order_id: orderId, sku: 'IP16-BLK-128', }, }, { amount: 2, precision: 1, currency: 'UNIT', reference: `${orderId}_delivered`, source: inTransit.data.balance_id, destination: delivered.data.balance_id, allow_overdraft: true, description: 'Customer confirmed delivery', meta_data: { order_id: orderId, sku: 'IP16-BLK-128', }, }, ], });200 OK{ "batch_id": "bulk_c62f200b-905f-4983-a349-cadd279234aa", "status": "inflight", "transaction_count": 3 }Field What it does atomic: true All three succeed together or none of them do. If available is short, none of the legs post. inflight: true Reserves each move until its condition is met. Available → sold waits on payment, but the 2 units are held so another checkout cannot take them. allow_overdraft On sold → in transit and in transit → delivered only. Available cannot go negative. source / destination Required. You can reconstruct where the seller’s units went. Commit each state when it is done
Things happen before a state changes. Inflight holds each transaction until they do, then you apply it on the ledger.
Move Before you commit Available → Sold Customer starts checkout. Customer makes payment. Payment succeeds. Sold → In transit Item picked up. Item packaged and handed over to the delivery partner. Delivery processing starts. In transit → Delivered Partner delivers the item. Customer confirms delivery. Partner confirms delivery. For each movement, you commit when all its conditions are met.
commitSold.tsconst soldMove = await blnk.Transactions.getByReference(`${orderId}_sold`); await blnk.Transactions.updateStatus(soldMove.data.transaction_id, { status: 'commit', });_in_transitand_deliveredare the same call when those conditions are met.Correcting a mistake
Say the amount was 3, not 2. You do not edit the inflight record. Void the bulk. That releases the reserved units and resets the balances. Then you write a new transaction with the right amount.
The ledger keeps both writes, so you can show the error and the fix. An items table would have overwritten the number and left no trail.
voidAndRetry.tsawait blnk.Transactions.updateStatus(sale.data.batch_id, { status: 'void', }); const retryId = `${orderId}_v2`; const sale = await blnk.Transactions.createBulk({ atomic: true, inflight: true, transactions: [ { amount: 3, precision: 1, currency: 'UNIT', reference: `${retryId}_sold`, source: available.data.balance_id, destination: sold.data.balance_id, description: 'Checkout paid', meta_data: { order_id: retryId, sku: 'IP16-BLK-128', }, }, { amount: 3, precision: 1, currency: 'UNIT', reference: `${retryId}_in_transit`, source: sold.data.balance_id, destination: inTransit.data.balance_id, allow_overdraft: true, description: 'Picked up for delivery', meta_data: { order_id: retryId, sku: 'IP16-BLK-128', }, }, { amount: 3, precision: 1, currency: 'UNIT', reference: `${retryId}_delivered`, source: inTransit.data.balance_id, destination: delivered.data.balance_id, allow_overdraft: true, description: 'Customer confirmed delivery', meta_data: { order_id: retryId, sku: 'IP16-BLK-128', }, }, ], });
Extract any snapshot from your ledger
Let's say the seller raises a dispute and asks how their available got to 31. With your ledger, you can replay from that date and show them every move that took it down to 31.
If it is a pending delivery, those units are on sold or in transit. They are not missing.
Filter on meta_data.sku and you get the full history:
const history = await blnk.Search.filter(
{
filters: [
{
field: 'meta_data.sku',
operator: 'eq',
value: 'IP16-BLK-128',
},
],
sort_by: 'created_at',
sort_order: 'desc',
include_count: true,
},
'transactions',
); If they claim their registered available inventory was 50 on April 1st, 2026, you can show them what your system actually had recorded on that day. Get the SKU's available balance at that timestamp:
const atConfirm = await blnk.LedgerBalances.getAt(available.data.balance_id, {
timestamp: '2026-04-01T09:00:00.000000Z',
}); Conclusion
The ledger does not replace your inventory management product. It is what makes that product reliable and defensible. The best architecture has your product logic — states, conditions, insights, and admin UX — built on top of a double-entry, immutable ledger that sits underneath and holds correctness at every step.
If you want to give it a try, start here: Blnk Cloud.