Skip to main content
    All articles

    CNR-Driven Integrations: Wiring Case Identity into Your Product

    7 July 202614 min readCourtMesh Team
    Cover card headed Case Identity Is a Schema Decision, with the line: normalise on write

    There is a moment in every legal product where somebody decides what the matter table is keyed on. It usually takes about ten minutes and it usually goes to the case number, because that is what appears on the file, what the lawyer says on the phone, and what the client types into the search box. It is also the single most expensive schema decision available in this domain, and by the time the cost shows up the table has three years of foreign keys pointing at it.

    This piece is not about why a lawyer should care about case identity across a dispute's lifecycle. That argument is made elsewhere, and it is a good one. This is the engineering half: how identifiers behave in Indian court data, what the CNR does and does not guarantee, how a court data API normalises what you send it, and the ingestion patterns that keep a product working after the fifth court and the second thousand matters.

    The thesis is narrow and testable. Products that normalise identifiers at write time outlive products that normalise at query time, and the difference compounds with every feature you add on top.

    What the CNR Is, and What It Is Not

    The Case Number Record identifier, the CNR, is a stable identifier assigned to a matter in the eCourts system and carried on the case record. On the CourtMesh API it appears as the cnr field on a record returned by GET /cases/:id. Two properties make it valuable to an integrator.

    The first is that it does not change when the display number does. A matter registered under one number and later renumbered by the registry keeps its identifier. That alone solves the most annoying class of bug in a tracker, where a perfectly working row stops matching upstream because a registry updated a number nobody told you about.

    The second is that it is unambiguous across courts in a way a case number simply is not. A case number is scoped to a court, a case type and a year, and outside that scope it is a common string that many other matters also carry. An identifier that carries its own court context is a different kind of object, and it is the right kind of object to key a join on.

    The boundary that catches everyone

    The identifier identifies a matter, not a dispute. When your client's suit goes up on appeal, the appeal is a different matter, in a different court, on a different record, with a different identifier. The identifier did not follow the dispute, because it was never a dispute level object. A product that assumes otherwise will show a user a decided matter and no indication that the fight is very much alive one floor up.

    It also does not cover everything. Not every record in a large corpus carries one, particularly older records and records from sources that predate or sit outside the eCourts flow. Tribunal records follow their own numbering conventions. A data model that assumes the field is always present is a data model that will meet a null in production and behave badly.

    Identifier Normalisation, Concretely

    Before you can join on anything you have to agree on what a given string means, and Indian case numbers arrive in a remarkable variety of shapes for the same matter. The same appeal is written 123/2024 on the cause list, 123-2024 in an internal spreadsheet, No. 123 of 2024 in the body of an order, and CA 123 2024 by whoever last typed it into your system at speed.

    The API does normalise on its side, and knowing how is useful. In the semantic search pipeline at POST /search/cases/semantic, the incoming query is normalised before anything else happens: case numbers written as 123-2024 or 123/2024 are collapsed to 1232024 and captured separately as candidate case numbers, so the model extracting structured filters sees them as identifiers rather than as prose. That behaviour is why typing a case number into a semantic search box works at all, and why a query that consists only of an identifier ends up shorter than three characters after cleaning and falls back to keyword search with meta.fallbackMode set to opensearch.

    GET /cases/:id is similarly forgiving: the id segment accepts either the internal case id or a case number. That is a convenience during development and a trap in production, because it means a lookup that works in your test with a hand picked number will also appear to work with an ambiguous one, right up until it returns a matter from a different court that happens to share the digits.

    Case numbers are not globally unique

    Case number 123 of 2024 exists many times over. The same digits recur across courts, across case types and across years, and nothing in the string tells you which one you have. A case number is only an identifier inside a scope of court, type and year. Outside that scope it is a label, and a label is not a key.

    So your normalisation has two jobs, and they are different. One is producing a canonical comparable form of a string so that 123/2024 and 123-2024 land in the same bucket. The other is establishing scope so that the bucket refers to one matter rather than to forty. Most teams build the first and skip the second, and the resulting bug is a user seeing somebody else's case, which in this domain is not a minor defect.

    The Ingestion Pattern

    Here is the shape that holds. It is not sophisticated. It is simply the version where every decision is made once, at write time, rather than repeatedly and inconsistently at every read site in your codebase.

    1

    Capture the raw string exactly as it arrived

    Whatever the user typed, whatever the import file contained, character for character, in its own column. This is your audit trail and your debugging surface. When a match fails eight months from now, the raw string is the only thing that tells you what actually happened, and no amount of logging substitutes for having kept it.

    2

    Normalise once, deterministically, at write time

    Uppercase, strip whitespace and punctuation, collapse separators, and resolve of and year noise into a single canonical form. Store the result in its own column beside the raw string. The function that does this is a pure function with a version number, because you will change it and you will need to know which rows were written under which version.

    3

    Resolve scope before you resolve identity

    A normalised number alone is not enough to look anything up safely. Carry court and year with it. When you resolve against the API, send them: POST /search/cases accepts caseNumber together with court, caseType and year, and each of those accepts a string, a comma separated string or an array. A resolution query without a court filter is a guess.

    4

    Persist the resolved identity, not the display string

    Once a resolution succeeds, store the internal case id and the cnr from the returned record. Those become your join keys for every subsequent call. Everything a user sees is a display value read from a separate column and never used to fetch anything.

    5

    Keep a per matter list of every identifier ever seen

    One matter, many numbers over its life: a filing number, a registration number, a renumbered form after transfer, the numbers of applications inside it. Model that as a child table of identifiers with a type and a first seen timestamp, not as a column you overwrite. An overwritten column is a lookup that used to work and now silently does not.

    6

    Never key the primary table on a display number

    Your matter table gets an internal surrogate key that means nothing to anybody. Every court identifier hangs off it. This is the decision that costs nothing on day one and saves you a migration on day four hundred, when a registry renumbers a matter that thirty of your rows point at.

    Normalisation at query time is a rule enforced by whoever remembered it. Normalisation at write time is a rule enforced by the schema. Only one of those survives a new engineer joining the team.

    Why Write Time Beats Query Time

    The argument for normalising at query time is always the same and always superficially reasonable: keep the raw data pristine, apply the transformation in the lookup, and you have lost nothing. It falls apart for four reasons, and they arrive in this order.

    ProblemNormalise at query timeNormalise at write time
    Consistency across call sitesEvery place that looks up a case has to apply the same transformation. There are eventually eleven such places, written by four people, and two of them differ.One function, one column, one behaviour. Call sites compare columns and cannot get it wrong.
    IndexingYou are matching on a computed expression, so either the index does not apply or you carry a functional index that has to be kept in step with application code.A plain indexed column. Lookups are ordinary lookups and stay fast as the table grows.
    Deduplication on importTwo spreadsheet rows for the same matter written differently both insert, because the comparison happens later. You now have duplicates in the source of truth.The canonical column carries a uniqueness constraint within scope, so the duplicate is caught at the moment it would have been created.
    Changing the rulesA change to the transformation silently changes what every historical query returns, with no record of which rows were affected or when.A version bump plus a backfill. The change is an explicit, auditable migration you can reason about and roll back.
    Debugging a failed matchYou have the raw value and a guess about what the transformation produced. Reproducing the failure means running application code by hand.You have both forms side by side in the row. The diagnosis is a single query.

    The fifth reason is the one that actually decides it, and it is not technical. Query time normalisation makes correctness a matter of discipline, and discipline degrades with headcount. Write time normalisation makes correctness a property of the data. Six months in, the second product still works and the first has a support queue.

    A Resolution Flow That Fails Safely

    Resolution is the step where a string a human supplied becomes a record you can fetch. It should be explicitly modelled as a state machine with an unresolved and an ambiguous state, because both occur constantly and a flow with only a success path will invent an answer.

    1. Classify the input. If it matches the shape of a stable identifier, treat it as one. If it looks like a case number, treat it as scoped and demand court and year before proceeding. If it is neither, it is prose, and prose belongs in search rather than in resolution.
    2. Resolve scoped numbers through search, not through a direct fetch. Send POST /search/cases with caseNumber, court and year rather than putting a number into GET /cases/:id and hoping. Search returns a set, and a set is the honest answer to an ambiguous input.
    3. Treat more than one result as ambiguity, not as a ranking problem. If the search returns several matters, the correct behaviour is to record the state as ambiguous and put the candidates in front of a human with title, court and decisionDate visible. Silently taking the first result is how a tracker ends up following the wrong matter for a year.
    4. Record zero results as unresolved and retryable. A number that resolves to nothing today may resolve next week once the registry publishes. Keep it in the queue with an attempt count rather than discarding it or marking it invalid.
    5. Persist the identity, then verify it once. After resolution, call GET /cases/:id with the internal id and confirm the record you get back is the one the human confirmed. That single verification call closes the loop between what your database believes and what the corpus actually holds.
    6. Re resolve on a schedule for live matters only. A decided matter is immutable and never needs re resolving. Pending matters move, so a periodic refresh keyed on the internal id and stamped with a checked timestamp is sufficient, and it costs one call per matter per cycle.

    Budget the resolution queue

    An API key is allowed 10 requests per minute, so identifier resolution is a queue rather than a loop. Resolve in the background with a durable job per identifier, checkpoint after each one, and make the resolution status visible in your own UI. A user who can see that a matter is pending resolution will wait. A user watching a spinner that never resolves will file a bug.

    Modelling the Dispute Above the Matter

    Because the identifier is a matter level object and disputes span matters, a serious product needs one more layer, and it is worth naming explicitly rather than letting it emerge by accident.

    Model a dispute as an entity in its own right, with matters hanging off it. Each matter carries its own court, its own numbers and its own identifier. The suit in the District Court, the appeal in the High Court, the SLP, the execution petition and the arbitration under section 34 of the Arbitration and Conciliation Act 1996 are separate matters that belong to one dispute. Nothing in the public record will assemble that grouping for you. It is your product's editorial layer and it is where genuine value sits, because no amount of upstream data will tell you that these five records are the same fight.

    GET /cases/:id/related helps within a matter but should not be mistaken for the dispute layer. It gathers every stored document sharing the same case number, sorted by decision date ascending and capped at 50, and returns both a relatedDocuments array with id, title, caseNumber, court, decisionDate, caseType and an isCurrent flag, and a timeline whose entries carry a date, a status of either Final Judgment or Hearings and Orders, a statusLabel that prefers the disposal nature where present, and a documentId. That is the order history of one matter assembled for you, which is genuinely useful and saves a call per document. It is not the chain from suit to appeal, and it comes back empty when the case has no case number at all.

    A primary key on a display case number that a registry later renumbers
    Resolution without a court filter, silently returning a matter from another state
    An identifier column that gets overwritten instead of appended to a history
    Assuming the cnr field is present on every record, including older and tribunal records
    Treating a matter as the dispute, so a decided suit hides a live appeal from the user
    Normalisation logic duplicated across call sites, with two of them subtly different

    Operational Details Worth Knowing Before You Build

    A few API behaviours change how you design around identifiers, and they are cheaper to learn now than to discover in an incident.

    • Validation errors are specific. A failed request comes back with a success flag of false, an error string, and for validation failures a details array carrying one human readable message per offending field, prefixed by the field path. Surface those verbatim into your import logs rather than collapsing them into a generic failure, because they tell you exactly which field your normaliser mangled.
    • Filter fields accept several shapes. court, caseType, caseNumber and the judge fields each accept a string, a comma separated string or an array. Pick one shape and use it everywhere, because a mixed convention across your codebase is a class of bug that only appears with multi value inputs.
    • caseType expands server side. A primary type is expanded into the underlying registry codes, so a filter on Appeal resolves to a set such as CA, CRA and LPA. Convenient for retrieval, and worth knowing when you are reasoning about why a resolution returned more candidates than you expected.
    • PDF links are temporary. GET /cases/:id/pdf returns a pdfUrl with an expiresIn of 3600 seconds. It is a presigned link, generated per request. Store the case id and generate a fresh link on demand. Never persist that URL as though it were a permanent address for the document.
    • Some fields are simply absent. Many case fields are optional and missing wherever the registry never published them. Your schema should permit nulls everywhere the API permits absence, and your UI should distinguish not published from not yet fetched.
    • Text fields are watermarked per key owner. Records returned by the case endpoints carry watermarking for leak tracing. Treat the text as licensed content in your storage and access design rather than as anonymous public data you may redistribute freely.

    The Payoff

    None of this is difficult. The whole pattern is a raw column, a canonical column, a scope, an identifier history table, a surrogate primary key and a resolution state machine. A competent engineer can build it in a week, and the reason it so often does not get built is that on day one every one of those pieces looks like over engineering for a problem you have not met yet.

    You will meet it. You meet it the first time a registry renumbers a matter your users are watching. You meet it when a client uploads a spreadsheet with four spellings of the same number. You meet it when a partner asks why the system showed a matter as disposed while the appeal was being argued. Each of those is a schema problem wearing a support ticket, and none of them is fixable at the call site.

    Get the identity layer right and everything above it becomes ordinary engineering: tracking, alerting, analytics, timelines and reporting all reduce to joins against a key that does not move. Get it wrong and every feature you build inherits the ambiguity, permanently. The endpoint and field reference is at the API documentation, and the coverage that identity layer resolves against is set out at the API overview.

    Decide the key before you write the second feature

    Case identity is a schema decision, not a lookup detail. Normalise at write time, keep the raw string beside the canonical one, resolve within a scope of court and year, keep every identifier a matter has ever carried, and key your primary table on something no registry can renumber. CourtMesh exposes the cnr field, case detail, related documents and timelines over roughly 310 million cases sourced directly from official government portals. The full endpoint and field reference is at the API documentation, coverage is at the API overview, and call costs are at API pricing.

    Explore CourtMesh
    CNRAPIIntegrationCase TrackingDevelopers
    X LinkedIn