collected_by: BAP),
holding it until each tenant reconciles.
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.
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.
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. 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.
collected_by: BAP),
holding it until each tenant reconciles.
provider_id and handed to the right store's
backend. Status pushes flow back through the same demuxer.
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.
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.
DELIVERY. The store is committing to deliver to a stated location — not just hand the parcel to a carrier.store-a). There is no separate Logistics BPP — the provider on the fulfillment is the same legal entity as the provider on the item./on_status [PICKED_UP] with a proxy phone number for the in-house driver or contracted courier.DELIVERY_CHARGE line alongside ITEM and TAX. The store computes this from drop pincode, weight, and its own rate card.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./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.
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.
/on_reconcile, run once per tenant, carrying the settlement statement and NTPN — never the funds. 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.PRE-FULFILLMENT. Andi authorises at /confirm, before any store dispatches. The BAP holds the funds until each tenant's contract reconciles./on_reconcile carries your marketplace fee as its own breakup line. Tenant payout = their order total − fee − costs already netted in their quote./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.
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.
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.
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.
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.
provider_id for audit; you don't need separate Beckn signatures per tenant.npwp + fakturPajakReference per tenant; surface them on /on_confirm and the reconcile bundle./on_select calls the tenant's pricing service — never a shared one.availability.status reads from the tenant's stock service./on_status [SHIPPED] from the tenant's dispatch system./on_reconcile.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).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.