Beckn-ONIX is the middleware that sits between a buyer or seller's backend and the open Beckn network. It validates, signs, routes, and publishes every message — and it does so through a plugin pipeline you assemble yourself. This is a working tour of what moves where, and why.
ONIX is not the buyer app and not the seller's backend. It is the protocol layer between them — a stateless gateway that turns private backend calls into signed, validated Beckn messages on a public network, and turns inbound network messages back into clean calls for the backend.
Each ONIX node speaks two languages. To the backend it speaks HTTP-and-JSON in whatever shape the operator prefers. To the network it speaks the Beckn v2.0 protocol — strictly signed, strictly typed, strictly versioned. Everything in this document is about how it negotiates between those two sides.
ONIX is a single Go process. Inside it, every request descends through four layers before doing real work: the HTTP server, the module router, the handler, and the step pipeline that does the actual protocol translation.
L1 — HTTP server is just net/http. It enforces
read, write, and idle timeouts and otherwise stays out of the way.
L2 — Module router matches the URL path prefix to a module.
A module is bound to one of four well-known shapes: bapTxnCaller,
bapTxnReceiver, bppTxnReceiver, bppTxnCaller.
One adapter process may host any combination.
L3 — Handler is the only handler type that ships:
stdHandler. It loads the plugins declared in config,
attaches middleware, opens an OpenTelemetry span, and hands the
request to the step pipeline.
L4 — Step pipeline is where everything interesting happens. Steps run in declared order. The first step that errors aborts the pipeline and produces a signed NACK.
A node may play one role (BAP or BPP) or both. Each role splits in two directions — outbound calls into the network and inbound calls from the network. That gives four module shapes. Pick the ones relevant to the deployment.
The pattern is symmetric. For every verb on the
Caller side there is a matching on_*
verb on the Receiver side. The two sides do not
block on each other — Beckn is asynchronous by design, and the
callback may arrive seconds or minutes later from a different
participant.
Walk through what happens when a buyer's backend calls
POST /bap/caller/search. Click each stage to expand.
Step order is declared in the handler config and matters. The
most common ordering is: verify what came in
(validateSign → validateSchema),
decide where it goes (addRoute), prepare what
goes out (sign), and finally hand it off
(publish or HTTP forward). On the BPP receiver
side you typically see checkPolicy inserted between
schema validation and routing — a place to apply manifest-driven
allow/deny rules before doing real work.
If any step returns an error the pipeline aborts. The handler
composes a Beckn NACK, signs it with the
ackSigner when configured, and returns it
synchronously with an appropriate HTTP status. Every step
also emits OpenTelemetry metrics —
onix_step_executions_total,
onix_step_execution_duration_seconds,
and onix_step_errors_total — labelled with
the module, action, and step name.
# a typical bapTxnCaller handler — config/local-simple.yaml modules: - name: bapTxnCaller path: /bap/caller/ handler: type: std role: bap plugins: router: { id: router, config: { routingConfig: ./config/local-routing.yaml } } schemaValidator: { id: schemav2validator, config: { type: url, location: https://.../beckn.yaml } } signer: { id: signer } keyManager: { id: simplekeymanager } cache: { id: cache, config: { addr: localhost:6379 } } steps: - validateSchema - addRoute - sign - publish
Every job a handler can do is delegated to a plugin. Plugins are
loaded as Go .so files at startup, slotted into named
positions, and used by the steps. ONIX ships a working
implementation for each slot; replace any of them without
touching ONIX itself.
Authorization header on inbound requests and ACKs.simplekeymanager for dev, keymanager for Vault, secretskeymanager for GCP/AWS secret stores.schemav2validator can also pull domain-specific extended schemas via @context.bpp_uri.http.RoundTripper to add retries, observability, or custom headers.message_id and transaction_id. The foundation under stateful plugins.Step interface can be referenced from steps:.http.Handler wrappers. Runs before the pipeline — auth, rate limiting, logging.The same binary serves every deployment. What changes is the set of modules declared in the config file. Scale the role that needs scaling; collapse both into one when the load is modest.
Beyond the role split, the same binary supports local-development
shapes that swap heavy dependencies for embedded ones —
simplekeymanager instead of Vault,
in-process publishers instead of RabbitMQ, file-based schemas
instead of remote ones. Production configs simply re-bind those
slots to real backends. Nothing about the pipeline changes.
Every node emits a single OpenTelemetry stream — metrics, traces, audit logs — over OTLP. A companion collector deployed beside the adapter splits that stream into two pipelines: one for the node operator, one for the network observer.
The node operator gets full-fidelity visibility into their own
adapter — every step's latency, every cache hit, every plugin
error — and can ship it to any OTLP backend they choose. The
network observer gets only what crosses node boundaries: an
http-request counter labelled by sender.id and
recipient.id, a stitched distributed trace where
the Beckn transaction_id becomes the trace ID,
and a plugin-inventory gauge that reveals which plugins each
node is actually running.
Audit logs are emitted with PII masked at the source. The
fields to mask are themselves configuration —
config/audit-fields.yaml — so the network
observer never receives anything sensitive even when the
node operator's pipeline does.