Skip to content
PRSINDIA

Product

The ONDC integration guide we wish existed

Everything is asynchronous, the signing will eat two weeks, and the callback is the real API. Here is the map.

PRS Admin 8 min read

Most ONDC documentation is written for policymakers. The engineering documentation, where it exists, assumes you already understand the Beckn protocol, and the Beckn documentation assumes you already understand ONDC. We have taken three products through network onboarding. This is what we would have wanted on day one.

First: ONDC is not a marketplace

You do not "list on ONDC" the way you list on a marketplace. ONDC is a protocol plus a registry. You build software that speaks the protocol, you register your public key, and then you can transact with anyone else on the network. There is no ONDC-owned storefront and no ONDC-owned catalogue.

You are one of two things, and pick carefully because it determines everything:

  • BAP (Buyer App) — you have the customer. You search across sellers, place orders on their behalf, and own the customer relationship and the support burden for issues you did not cause.
  • BPP (Seller App) — you have the inventory. You publish a catalogue, accept orders from any buyer app, and fulfil them.

Between the two sits the gateway, which broadcasts a buyer's search to relevant sellers, and the registry, which holds everyone's public keys and network identities.

The single most important thing: everything is asynchronous

This is the fact that breaks every team's first architecture, so internalise it before you write a line of code.

When a BAP calls POST /search on the gateway, the response is not the search results. The response is an ACK — "I have received your request" — returned in milliseconds. The actual catalogues arrive later, as separate inbound HTTP requests from each seller app to your /on_search endpoint. Possibly a dozen of them. Possibly over the next several seconds. Possibly never, from a seller that is down.

BAP  --POST /search------->  Gateway     (returns { message: { ack: { status: "ACK" }}})
                                |
                                +--POST /search--> BPP-1
                                +--POST /search--> BPP-2
                                +--POST /search--> BPP-3

BPP-1 --POST /on_search-->  BAP          (here are the actual results, 900ms later)
BPP-2 --POST /on_search-->  BAP          (2.1s later)
BPP-3 --(never responds)

Every single verb works this way: search/on_search, select/on_select, init/on_init, confirm/on_confirm, status/on_status, track/on_track, cancel/on_cancel, update/on_update, rating/on_rating, support/on_support, plus the issue-and-grievance verbs.

Architectural consequences, all non-negotiable:

  1. Your callback endpoints are the real API. They must be publicly reachable, fast, and idempotent — the same on_confirm can arrive twice.
  2. You need a state machine per transaction, persisted. Not an in-memory promise, not a Redis key with a TTL you guessed. A row in a database.
  3. Model your data around transaction_id and message_id. The transaction_id is a UUID that spans an entire customer journey from first search to final delivery. The message_id is per request/response pair. Correlate on both, and index both.
  4. You need timeouts and partial results. The UI must show whatever came back within your window (typically 2–4 seconds for search) and move on. Waiting for all sellers means waiting for the slowest, which means waiting for the dead one.

If your team's instinct is to write const results = await search(query), stop and redesign now. That function cannot exist.

The signing, which will eat two weeks

This is the number-one onboarding blocker and it is worth being precise about.

Every request carries an Authorization header containing an Ed25519 signature over a specific signing string, which includes a BLAKE2b-512 digest of the raw request body. Not SHA-256. Not the parsed and re-serialised body — the exact bytes you received or are sending. Reserialising the JSON, even to add whitespace, changes the digest and invalidates the signature. In Express, this means capturing the raw body before express.json() touches it; in Laravel, $request->getContent(), never json_encode($request->all()).

import { blake2bHex } from "blakejs";
import nacl from "tweetnacl";

function signRequest(rawBody: string, privateKeyB64: string, subscriberId: string, uniqueKeyId: string) {
  const created = Math.floor(Date.now() / 1000);
  const expires = created + 30;

  // BLAKE2b-512, base64. This is the part everyone gets wrong.
  const digest = Buffer.from(blake2bHex(rawBody, undefined, 64), "hex").toString("base64");

  const signingString =
    `(created): ${created}\n` +
    `(expires): ${expires}\n` +
    `digest: BLAKE-512=${digest}`;

  const signature = Buffer.from(
    nacl.sign.detached(Buffer.from(signingString, "utf8"), Buffer.from(privateKeyB64, "base64"))
  ).toString("base64");

  return `Signature keyId="${subscriberId}|${uniqueKeyId}|ed25519",` +
         `algorithm="ed25519",created="${created}",expires="${expires}",` +
         `headers="(created) (expires) digest",signature="${signature}"`;
}

And then there is /on_subscribe, the registration challenge. When you register in the registry, it POSTs an encrypted challenge string to your /on_subscribe endpoint. To answer it you must:

  1. Derive a shared secret via X25519 between your encryption private key and ONDC's published public key (a different keypair from your signing keypair — this trips up nearly everyone).
  2. AES-256-ECB decrypt the challenge with that shared secret.
  3. Return the plaintext as JSON within a few seconds.

You get two keypairs: Ed25519 for signing, X25519 for encryption. They are not interchangeable and the error messages will not tell you which one you got wrong. Build a standalone test harness for the signing layer and get it green before you write any business logic. Every team that skips this spends a fortnight in a debugging swamp with a NACK and no diagnostic.

The catalogue (BPP side)

Your on_search response contains a Beckn catalogue: providers, items, categories, fulfilments, locations. Things that matter more than they appear to:

  • ID stability. Provider IDs and item IDs must be stable forever. If a regenerated catalogue changes item IDs, every buyer app's cached reference breaks, and re-orders and returns break with them.
  • Serviceability. Declared per fulfilment, by PIN code, polygon or radius. Get this wrong and you receive orders you cannot deliver, which damages your network rating — a real, scored metric that affects how much traffic you see.
  • TAT (turnaround time) is expressed as an ISO 8601 duration (P1D, PT4H). Buyer apps rank on it. Optimism here converts directly into cancellations and a worse rating.
  • Incremental catalogue refresh exists and you should implement it. Pushing a full catalogue on every search does not scale past a few thousand SKUs, and the gateway will notice.
  • Price and stock must be true at select time. The select call is the buyer app asking "is this still real?" A quote that does not match at init is a protocol violation and a customer complaint.

The order lifecycle, and the parts that hurt

selectinitconfirm is the happy path, and it is the easy part. The hard parts are all after:

  • Cancellation codes are standardised and they matter. Who cancelled, and why, determines who bears the cost. Do not map everything to a generic code because the correct one was inconvenient to determine.
  • RTO (return to origin) has its own flow, its own charges and its own settlement implications. It is not a cancellation and modelling it as one will corrupt your reconciliation.
  • Settlement and reconciliation. Money moves on a schedule that is decoupled from the order. You need a ledger that reconciles what you were owed against what settled, per order, and it will not always match. Build this from the start; retrofitting a ledger onto six months of unreconciled orders is grim work.
  • IGM (Issue and Grievance Management) is a whole protocol of its own, with response-time SLAs. It is not optional, and teams routinely discover it two weeks before go-live.

Testing and certification

There are staging and pre-production environments, and a log-based certification process: you execute a prescribed set of scenarios and submit the request/response logs for review. Two things to know:

  • Build a mock counterparty — if you are a BAP, build a fake BPP you control. You cannot debug against a live seller who has no idea who you are and no reason to help.
  • Certification will find things. Budget two to three weeks for the loop of submit, get feedback, fix, resubmit. Nobody passes first time.

An honest timeline

For a competent team that has not done this before, building a BPP against an existing commerce backend:

  • Signing + registry onboarding + /on_subscribe: 2 weeks (it will feel like it should be two days)
  • Async transaction core, state machine, callback endpoints: 2–3 weeks
  • Catalogue mapping and serviceability: 2 weeks (longer if your product data is messy, which it is)
  • Full order lifecycle incl. cancel, RTO, IGM: 2–3 weeks
  • Certification loop: 2–3 weeks

8 to 12 weeks. Anybody quoting you two weeks has either built it before and is reusing everything, or has not read the specification. There is no shortcut through the signing layer and no way to make the callbacks synchronous.

The mistake is not technical. It is treating ONDC as a REST integration when it is an asynchronous, federated, cryptographically-signed protocol. Get the architecture right and the rest is tedious. Get it wrong and you rewrite in month three.

Written by

PRS Admin

Building software at PRS India.

Keep reading

All articles

Engineering

Server Components changed how we structure Next.js apps — our current default

Read it

Engineering

Laravel queues in production: four failure modes nobody warns you about

Read it

Business of Software

What an ERP migration actually costs — a line-item breakdown

Read it