A practical guide · Multi-store BPP + BAP

Build two adapters:
one buyer app,
one multi-tenant seller.

You're building a BPP that fronts many stores — each with its own inventory, its own prices, and its own shipping — and a BAP that aggregates listings (including yours) into a single buyer experience. The trick is that all your stores share one adapter process, each ships in-house on the Trade contract, and your marketplace BAP collects the payment.

2
Adapters to build
N
Tenant stores
1
BPP process
BAP
Collects payment
01 The picture

One BPP, many stores.

Your BAP talks to the network. The network sends every request to your BPP. Your BPP is one backend process that switches on provider_id and runs the right tenant's fulfilment — its own warehouse, its own courier (or in-house driver), its own door-to-door delivery.

Topology: single-backend BPP with internal provider switch Consumer, BAP, network, and an ONIX-BPP whose Router forwards every message to one BPP backend that switches on provider_id to three tenant fulfilment branches, each shipping directly to the consumer. fig.01 topology · 1 BAP · 1 BPP · N tenants One BPP backend · internal switch on provider_id CONSUMER Andi Surabaya YOUR BAP · BUYER Buyer app + ONIX-BAP aggregates N BPPs port :8081 network gateway · registry YOUR ONIX-BPP Router forward all → one route · stateless YOUR BPP BACKEND one process switch (provider_id) case store-a → Toko Elektronik case store-b → Roti Sumber case store-c → Batik Solo STORE A · FULFILMENT Toko Elektronik own driver fleet STORE B · FULFILMENT Roti Sumber own scooter rider STORE C · FULFILMENT Batik Solo contracted carrier Beckn 2.0 over the network async on_* callbacks forward all · one route dispatch in-house shipping · store → buyer
fig. 01 The Router forwards every message to one backend (moss = private API), which switches on provider_id to the right tenant's fulfilment. The amber dashed line is the parcel leaving each store directly to Andi's door, never via a separate Logistics BPP. Beckn over the network is rust; async callbacks return on the muted grey path.
02 The two halves

Build the buyer side and the seller side.

Two adapters. They never share state — they only exchange Beckn messages through the network. The BAP cares about discovery and the consumer's order; the BPP cares about tenant routing and fulfilment. Each side speaks ONIX at the network edge and your private API at the backend edge.

BAP · buyer side
aggregator
Subscribes to catalogues from every BPP you care about (yours and others'). Maintains a local index so search runs without a live network hop. When the consumer commits, dispatches Beckn calls to the BPP that owns the catalog hit — and collects the payment (collected_by: BAP), holding it until each tenant reconciles.
builds  local index · provider chooser · /select dispatcher · /on_* receiver · payment escrow
BPP · seller side
multi-tenant fronting
One ONIX-BPP process. N catalog publishes — one per tenant — because each Beckn 2.0 catalog binds to a single provider. Every inbound request is demultiplexed by provider_id and handed to the right store's backend. Status pushes flow back through the same demuxer.
builds  per-tenant catalog publisher · tenant switch · store dispatcher · status forwarder
03 Multi-tenant catalogue

N catalogs, one provider each.

In Beckn 2.0 each catalogue binds to exactly one provider — beckn.yaml ties the Catalog schema to a single provider field, not an array. So your multi-tenant BPP publishes one catalogue per tenant. The BAP subscribes once to your BPP and receives each provider's catalog as a separate envelope; its local index keys on provider.id across all of them.

# beckn 2.0: one catalog, one provider — publish this once per tenant
catalog:
  descriptor:    { name: Toko Elektronik on Your Marketplace }
  provider:
    id:          store-a-toko-elektronik
    descriptor:  { name: Toko Elektronik }
    locations:   [ { id: JKT-001, gps: -6.20,106.84 } ]
    items:       [ { id: item-phone-x, price: 5,000,000, location_ids: [JKT-001] } ]
    fulfillments: [ { id: ful-A-next-day, type: DELIVERY, provider: store-a } ]

# your BPP backend emits one of these per tenant — 3 stores → 3 publishes
# adding tenant N+1 = one more publish; no other side learns about the others

Three things to keep straight: provider.id must be stable across publishes (the BAP keys its index on it), each catalog should declare its own fulfillments[] (so the BAP knows what shipping that store offers before calling /select), and policy IRIs live on the provider, not on the catalog root — so each tenant can advertise its own return-policy and SLA without affecting siblings.

04 In-house shipping

Fulfilment lives inside the Trade contract.

When each store ships its own goods, you don't need a separate Logistics BPP — and you don't need a linkedContractId. The shipping commitment is part of the Trade /on_select quote, the Trade /on_init firm price, and the Trade contract that the /confirm binds. Status pushes about the parcel come from the store, on the same Trade contract.

trade Inline shipping shape fulfillment carried by the same contract
fulfillment.type
DELIVERY. The store is committing to deliver to a stated location — not just hand the parcel to a carrier.
fulfillment.provider
The store itself (e.g. store-a). There is no separate Logistics BPP — the provider on the fulfillment is the same legal entity as the provider on the item.
fulfillment.agent
Populated at /on_status [PICKED_UP] with a proxy phone number for the in-house driver or contracted courier.
quote.breakup[*]
Includes a DELIVERY_CHARGE line alongside ITEM and TAX. The store computes this from drop pincode, weight, and its own rate card.
on_status states
PACKED → SHIPPED → OUT_FOR_DELIVERY → DELIVERED. The same Trade contract carries every one of them — there's no parallel Logistics contract to keep in sync.
deliveryProofUrl
Returned on /on_status [DELIVERED]. Closes the contract; /update [DELIVERED] moves it to COMPLETE.

Compare this to the Trade × Logistics walk-through: when shipping is a separate Beckn participant (JNE), it earns its own contract and the two link via linkedContractId. With in-house shipping, there is only one contract — and the store is fully responsible for the door-to-door commitment.

05 Marketplace payment

The marketplace collects; it never moves the money.

In this marketplace the BAP collects. Every order carries payment.collected_by = BAP: Andi pays your marketplace once — even for a cart spanning several stores — and you hold the funds, then remit each tenant at reconcile, net of your platform fee. ION never carries money; it carries the terms, a reference, and status. The funds move off-network over QRIS in and BI-FAST out.

Marketplace escrow: BAP collects once, remits each tenant at reconcile The consumer authorizes one off-network payment to the BAP, which holds the funds in escrow; at reconcile the BAP remits each tenant store its amount net of the platform fee, off-network, while the on-network /on_reconcile carries the settlement statement. fig.03 marketplace escrow · collected_by: BAP CONSUMER Andi one cart, N stores YOUR BAP · ESCROW holds funds collected_by: BAP − platform fee remits at reconcile STORE A · PAYOUT Toko Elektronik amount − fee STORE B · PAYOUT Roti Sumber amount − fee STORE C · PAYOUT Batik Solo amount − fee authorize once · QRIS off-network payout − fee · BI-FAST on-network: /on_reconcile per tenant → statement · NTPN
fig. 03 Money is muted grey, dashed, and always off-network — QRIS in from Andi, BI-FAST out to each store. The BAP holds it in between. The single rust line is the only on-network payment trace: /on_reconcile, run once per tenant, carrying the settlement statement and NTPN — never the funds.
payment Escrow shape BAP collects, settles per tenant
payment.collected_by
BAP. The buyer app collects; the BPP quotes the amount but never touches Andi's funds. Flip this to BPP and the model inverts — the store collects via its own instrument.
payment.type
PRE-FULFILLMENT. Andi authorises at /confirm, before any store dispatches. The BAP holds the funds until each tenant's contract reconciles.
one charge, N stores
A multi-store cart is one debit to Andi. Your BAP fans it into per-tenant Trade exchanges, but settles them all behind a single payment reference.
platform-fee line
Each /on_reconcile carries your marketplace fee as its own breakup line. Tenant payout = their order total − fee − costs already netted in their quote.
per-tenant remittance
/reconcile runs per Trade contract. The BAP pushes a BI-FAST payout off-network; the tenant returns NTPN and its Faktur Pajak reference on /on_reconcile.
refunds & cancellation
Because the BAP holds the funds, a refund before dispatch never touches the tenant. After dispatch it nets against that tenant's settlement.

This is one of three shapes Beckn allows. Set collected_by: BPP and each store collects directly with its own QRIS — see the Trade × Logistics walk-through. Or promote payment to its own sector with a dedicated Payment BPP (a PSP) that authorises and settles on-network. In all three, the BAP only ever orchestrates — it relays the request, drives the authorize step, and confirms the reference. The funds stay off-network.

06 The lifecycle

One purchase, one store, end to end.

Walk through one search → delivery cycle. Five lanes: Andi, your BAP, your ONIX-BPP, the chosen store's backend, and the ION services. Eight phases. The shipping events ride on the same contract as the order itself.

Lifecycle: one search-to-delivery cycle through a multi-tenant BPP Sequence diagram with lanes for Consumer, BAP, ONIX-BPP, Store backend, and ION Services. Phases: catalog publish, search/on_search, select/on_select, init/on_init, confirm/on_confirm, fulfilment pickup, transit status, delivery. Consumer Andi · Surabaya your BAP buyer app aggregator your ONIX-BPP multi-tenant routes by provider_id Store A backend Toko Elektronik own driver fleet ION Services Catalogue · CDS Registry · Policy Phase 01 · Catalog publish · one catalog per tenant · N publishes each tenant → BPP backend (private API) /catalog/publish · provider = store-a /catalog/publish · provider = store-b · beckn 2.0 — one provider per catalog /catalog/publish · provider = store-c /on_subscribe → BAP indexes every provider Phase 02 · Search · BAP fans out, BPP fans in search "smartphone" Andi opens your app /search your BAP queries your BPP across all tenants Phase 03 · /on_search · merged across tenants fan-out lookup your BPP queries every tenant in parallel tenant responses items · prices · fulfillments[] · availability /on_search · merged providers[] Phase 04 · /select → /on_select · routed by provider_id /select item · provider.id = store-a-toko-elektronik · drop pincode routed to Store A backend /on_select · quote with DELIVERY_CHARGE Phase 05 · /init → /on_init · firm price /init full address, fulfillment_id = ful-A-next-day, payment intent /on_init final breakup · payment.collected_by = BAP · amount due · expiresAt Phase 06 · /confirm → /on_confirm · contract ACTIVE /confirm consumer identity · BAP-held paymentReference (escrow) /on_confirm Trade contract ACTIVE · fakturPajakReference (Store A) · awb (Store A-issued) Phase 07 · Fulfilment events · pushed by Store A /on_status [PACKED] timestamp · packing photo · weighed weight /on_status [SHIPPED] agent name · vehicle plate · proxy phone /on_status [IN_TRANSIT] at hub · current node · estimated arrival /track BAP opens a live GPS session against Store A /on_track · websocket URL streaming coordinates /on_status [OUT_FOR_DELIVERY] Phase 08 · Delivered · contract COMPLETE /on_status [DELIVERED] deliveryProofUrl (photo) · agent signature /update [DELIVERED] Trade contract → COMPLETE push: "delivered" Phase 09 · Rating + reconcile · per-tenant settlement 5★ + comment /rate (PROVIDER, ITEM, FULFILLMENT) /reconcile (try=true · per-tenant) /on_reconcile Store A payout = total − platform fee · PPN · NTPN Epilogue One contract. One tenant. End to end. No linkedContractId · no parallel Logistics contract · the store is fully responsible. ~20 messages · 1 sector · 1 tenant · 1 contract
fig. 02 Trade messages in rust. Private API hops between your ONIX-BPP and the store backend in moss green. Shipping status pushes in amber, dashed. One contract, end to end.
07 Tenant routing

The buyer chose. The BPP only routes.

Start from what's already true when a message lands. In Beckn 2.0 the buyer picks the provider off the catalog — so by the time /select, /init, or /confirm reaches you, message.order.provider.id is already set. Your BPP never selects a store. It routes a message whose store is already named.

And it routes through exactly one mandatory step. Every ONIX pipeline ends in the Router plugin, which picks the next hop — a backend URL, the gateway, or a queue — and forwards. The Router is a forwarder, not a chooser; it doesn't pick the tenant, it just hands the message to your one backend.

One backend, one route. Give the Router a single rule: forward every verb to your one BPP backend. That backend reads provider.id and switches internally to the right tenant's inventory, rate card, and dispatch. The demultiplexing is a switch in your code, not a routing table in ONIX.

# config/local-routing.yaml — default: one backend, forward everything
routes:
  - when:     { }                                  # every verb, every provider
    target:   http://bpp-backend:9000/beckn        # one process; it switches on provider.id

/search is the one verb with no provider yet — the buyer hasn't chosen. Same one backend handles it: fan out to every tenant in parallel, merge, return a single /on_search with combined providers[]. The fan-out is internal — still no routing table.

Onboarding tenant N+1 never touches this routing config — the one rule already forwards everything. You add the new tenant inside the backend: register its provider.id in the switch, publish its catalog, load its secrets. No ONIX redeploy, no new route.

08 Operational concerns

What changes per tenant.

Multi-tenancy means everything that varies between stores has to be addressable by provider.id. Some of that lives in your BPP backend; some lives in ONIX configuration; some lives in the message itself. Get this list right before onboarding store #2.

ops Per-tenant axes eight things that vary by store
Signing keys
One BPP key pair signs the network edge — that's your identity. Inside the BPP backend, attribute each request to the tenant by provider_id for audit; you don't need separate Beckn signatures per tenant.
Tax IDs (Faktur Pajak)
Each tenant that's PKP-registered issues its own Faktur Pajak. Store the npwp + fakturPajakReference per tenant; surface them on /on_confirm and the reconcile bundle.
Delivery rate cards
Each store has its own pincode-aware rate logic, free-shipping thresholds, and fuel surcharge. The quote built in /on_select calls the tenant's pricing service — never a shared one.
Inventory & locations
Per-tenant warehouse list, per-item stock, per-location dispatch SLA. Catalog publishes one provider per tenant; availability.status reads from the tenant's stock service.
Drivers & fleet
In-house drivers are tenant employees. Names, vehicle plates, and (proxied) phone numbers are populated at /on_status [SHIPPED] from the tenant's dispatch system.
Settlement bank
Each tenant has its own payout VPA / bank account. Reconcile bundles per tenant; aggregate fee remittance to your marketplace happens via your platform-fee line on each /on_reconcile.
Observability
Add provider.id as a span attribute on every step in the BPP pipeline. Your dashboards filter by it; the network observer doesn't see it (privacy / commercial reasons).
Onboarding
Adding tenant N+1 is: register their provider.id in the backend's switch, publish a delta catalog with the new provider, register their NPWP/keys in the BPP backend. No new route, no redeploy of ONIX.

On the BAP side, the equivalent list is shorter: one local index, one consumer-identity store, one outbound dispatcher that picks the right BPP from the index hit. The asymmetry is the point — the BAP cares about discovery breadth across every BPP on the network, while the BPP cares about routing fidelity across every tenant inside it.