Skip to main content
    All articles

    What a Good Case Law API Response Looks Like

    9 June 202616 min readCourtMesh Team
    Cover card headed A Payload, Not a Database Row, with the line: every field optional

    Most integrations against a case law API break in the same place. Not at authentication, which fails loudly and is fixed in ten minutes, and not at the rate limit, which is published. They break three weeks in, on a Tuesday, when a user searches a District Court in a state nobody tested and the parser walks into a decisionDate that is not there.

    The root cause is a mental model, not a bug. Developers approach a case record the way they approach a row from their own database: a fixed set of columns, populated, with types you can rely on. A case record is nothing of the kind. It is a report on what one registry happened to publish about one proceeding, filtered through whatever the aggregation pipeline could reliably extract. Some registries publish a great deal. Some publish a title, a number and a date. The API faithfully reflects both.

    This piece dissects the anatomy of a case law payload: the wrapper that surrounds everything, the pagination envelope, the identifiers and why choosing the wrong one as your primary key is a migration waiting to happen, court metadata, party arrays, dates that may simply be absent, acts and sections, and the parts of the record that are deliberately never returned. The concrete example throughout is the CourtMesh API, whose full reference sits at the API documentation.

    The Wrapper Is Worth More Than an SDK

    Every successful response has the same four part shape: success set to true, data carrying the payload, meta carrying information about the request itself, and, on any endpoint that returns a list, pagination. Errors invert it cleanly: success set to false and an error string. Validation failures add a details array of human readable messages, one per offending field, each prefixed with the field path so you know exactly which parameter was rejected.

    That is the whole contract, and it holds across all twelve endpoints. It sounds unremarkable until you have integrated against an API that does not do it: one endpoint returning a bare array, another returning an object with the results under a differently named key, errors arriving sometimes as JSON and sometimes as an HTML error page from a proxy that nobody configured.

    One handler, twelve endpoints

    A consistent success and error wrapper saves integrators more time than any SDK, and the reason is that you can write one response handler for the entire API. It checks success, unwraps data, surfaces details on validation failure, reads pagination when present, and logs meta.responseTime. Every endpoint goes through it. An SDK gives you the same thing plus a dependency you did not write, cannot debug at three in the morning, and must upgrade on someone else's schedule. Given a consistent envelope, the forty lines you write yourself are better.

    The meta object earns its place too. It commonly echoes your query and the filters that were applied, which is how you confirm that the server understood what you sent rather than silently ignoring a parameter you misspelled. It carries responseTime as a string such as 412ms. And it sometimes carries a note, which is the API telling you what to do next in prose: on GET /cases/:id the note tells you that analysis is fetched separately, and on an analysis response with nothing stored it points you at the analyze endpoint.

    Branch on success, never on the HTTP status alone. Status codes are coarse and travel through infrastructure that can rewrite them. The envelope is written by the application that actually knows what happened.

    The Pagination Envelope, and Where Offsets Give Out

    Pagination is page, limit, total, totalPages and hasMore. Five fields, of which four are derivable and one is the one you should use.

    Use hasMore as your loop condition. It is tempting to compute continuation yourself by comparing page against totalPages, and it works right up until the corner case where total is an estimate or where results shift between requests. The server has already resolved that. Consuming hasMore means the edge cases live on one side of the boundary rather than being reimplemented, slightly differently, in every client.

    For POST /search/cases, limit defaults to 20 and caps at 100. The cap is real and asking for 500 does not get you 500. That matters when you size a backfill: your throughput is the page size multiplied by your rate limit, which for API key traffic is 10 requests per minute, so the ceiling arrives faster than most plans assume.

    There is also searchAfter, a string cursor. Offset based paging asks the search cluster to compute and discard everything before your offset, so page 500 is expensive in a way page 5 is not. A cursor carries the position forward and is the correct instrument for walking a large result set. Use page and limit for a user clicking through a screen of results. Use searchAfter for anything that traverses depth. Mixing the two in one traversal is how you end up with duplicated and skipped records.

    Three Identifiers, and Only One of Them Is Identity

    A case record carries several things that look like identifiers, and treating them as interchangeable is the most expensive modelling mistake available in this domain.

    IdentifierWhat it isSafe as a primary key?
    idThe internal identifier for this document in the corpus. Stable, unique, and what every follow-on endpoint path is built around.Yes. This is your key. Everything else is a lookup.
    caseNumberThe human readable registry number, a composite of case type, serial number and year, as displayed in cause lists and used by everyone in the matter.No. It is a display artefact and it repeats across courts and across years. Two unrelated matters can carry the same string.
    cnrThe stable case identifier assigned through the eCourts system, designed to point at one specific matter unambiguously.As a lookup, yes. As identity, carefully. It identifies a matter, and it may be absent on older records and presented inconsistently across forums, so treat its presence as something to check rather than assume.

    GET /cases/:id accepts either the internal id or a case number in the path. That is a genuine convenience and also the trap: because a case number resolves, it is easy to start passing case numbers around your system as though they were identity. They are not. The day a user searches a common number across two High Courts, your join produces two matters conflated into one, and the resulting bug is very hard to see because both records look plausible.

    Then there is the deeper conceptual point, which is not a data modelling issue at all but bites like one. A number identifies a proceeding, not a dispute. The same fight produces a filing number, a registration number, numbers for interlocutory applications, a fresh number on transfer, a wholly new matter on appeal, and another in execution. Your schema should have room for a dispute that owns many proceedings, because your users think in disputes and your API answers in proceedings.

    The Case Record, Field Group by Field Group

    GET /cases/:id returns the case record without analysis, which is a deliberate separation and one worth appreciating. Analysis is large, is present on only a subset of the corpus, and is not what most callers want on a list view. Keeping it behind GET /cases/:id/analysis means the detail call stays small and predictable.

    Court and jurisdiction

    court and courtName carry the forum, caseType the registry type, stateCode the state, and courtMetadata carries state, district and establishment names for District Court records. That last field is more useful than it appears: in a country with the district judiciary, an establishment name is often the only way to distinguish two courts that share a district. Do not flatten courtMetadata into a single display string on ingest. Keep the components, because someone will eventually want to filter by district.

    Parties, as arrays

    petitioners and respondents are arrays, and they are arrays because Indian litigation routinely involves many of each. A writ petition can name a dozen respondents. Any model that stores a single petitioner string, or takes the first element and discards the rest, has thrown away the information a diligence search actually depends on. It also breaks the moment someone wants to know whether a given entity appears anywhere in a matter rather than as the lead party.

    The judges field is an array for the same reason: a division bench has two, a constitution bench has five or more, and bench composition is legally significant when you are weighing precedential value.

    Dates, and the fact that they may not be there

    filingDate, registrationDate, decisionDate, nextHearingDate, lastListedOn and ordersFetchedAt. Six date fields, each of which can be absent, and each absent for a different and entirely legitimate reason. A pending matter has no decisionDate. A disposed matter has no meaningful nextHearingDate. An older record may have neither filingDate nor registrationDate because the registry never published them in a structured form. ordersFetchedAt describes your data's own freshness rather than anything about the case.

    Model every one of them as nullable and, critically, distinguish absent from unknown in your own display layer. A blank cell says the registry did not publish this. The string null in a document a lawyer sends to a client says your parser was written carelessly.

    Status and stage

    caseStatus, caseStage and disposalNature describe where a matter stands. They are meaningful only while the matter is live, and they age. A status fetched last quarter is not a fact about today. Store the timestamp at which you retrieved a status alongside the status itself, and show that timestamp to your users. caseHistory and ia_ma_history carry the procedural trail, including interlocutory and miscellaneous applications, which is frequently where the directions actually affecting the parties live while the main matter reads, accurately and unhelpfully, as pending.

    Substance: acts, sections and the summary layer

    acts and sections carry the statutory hooks, and they are how a user gets from a question about section 138 of the Negotiable Instruments Act 1881 or section 34 of the Arbitration and Conciliation Act 1996 to the matters that turn on it. They are also frequently empty, including on judgments that plainly turn on those provisions, because extraction depends on the source text being present and structured.

    summary, detailedSummary, headnote, holding and keyFacts are the interpretive layer. These are derived rather than published by the registry, which means their presence tracks the analysed subset of the corpus and not the corpus as a whole. Expect them on a minority of records, and design a view that still works when every one of them is missing.

    Documents

    hasOrders, orderCount and ordersFetchedAt describe stored documents. GET /cases/:id/pdf returns pdfUrl, expiresIn, caseId, caseNumber and caseTitle, where pdfUrl is an encrypted presigned link and expiresIn is 3600 seconds. A record with no stored document returns 404 with the message that a PDF is not available for this case. Never cache the pdfUrl as though it were permanent. It is time limited and issued per request. Store the case id and mint a fresh link when a user asks for one. Teams that cache the URL ship a feature that works perfectly in testing and produces dead links for users an hour later.

    Where the record reliably thins out

    Absence is not random. It follows the shape of the source, and knowing the pattern lets you design a view that degrades gracefully rather than one that looks broken.

    • District Court records are thinner than High Court records, which are thinner than Supreme Court records. This is the dominant axis and it is the one demo data never shows you, because demos use the Supreme Court.
    • Older records are thinner than recent ones in every forum. Structured filing and registration dates, in particular, become patchy the further back you go.
    • The summary layer tracks the analysed subset, not the corpus. summary, headnote, holding, keyFacts and detailedSummary are derived rather than published, so expect them on a minority of the roughly 310 million indexed records.
    • acts and sections can be empty on judgments that plainly turn on them, because extraction depends on the source text being present and parseable in the first place.
    • Party arrays vary in fullness. A lead petitioner may be recorded where the full array of respondents was never published, so an empty respondents array means unpublished rather than none.
    • Status fields are only meaningful while a matter is live. caseStage and nextHearingDate on a disposed matter are either absent or stale, and stale is the more dangerous of the two.
    • hasOrders true does not imply a full timeline, because timeline entries require both a date and a stored PDF. Sparse timelines are a fact about stored documents, not about the litigation.

    GET /cases/:id/analysis returns the stored AI analysis with a hasAnalysis flag, and the flag exists precisely because absence is normal. Where it is present the payload is rich: summary, detailedSummary, comprehensiveSummary, headnote, holding, keyFacts, issues, courtsReasoning, citedCases, precedentRelationships, arguments, practiceAreas, subCategories, tags, procedureType, precedentValue, legalPrinciples, doctrinesApplied, statutoryInterpretation, constitutionalProvisions, benchComposition, opinionType, factPattern, linkedCases and analysisSchemaVersion.

    One structural feature deserves particular care. The analysis carries parallel citation arrays: summaryCitations, holdingCitation, courtsReasoningCitations, keyFactsCitations, legalPrinciplesCitations. Entry i of a citation array corresponds to entry i of the matching content array. That correspondence is the grounding, and grounding is what makes model derived analysis checkable by a lawyer instead of something taken on trust. If your transformation layer sorts, filters or deduplicates one array without applying the identical operation to its partner, you have silently destroyed the link between a statement and its source. Keep them zipped into pairs at ingest, immediately, before any other processing touches them.

    analysisSchemaVersion is the other field worth respecting. Store it. When the schema evolves, that field is how you identify which of your stored records were produced under the older shape, and reprocessing the right subset is the difference between a quiet migration and a full backfill against a 10 requests per minute limit.

    GET /cases/:id/related returns relatedDocuments and timeline. Related documents share the same case number, sorted by decision date ascending, capped at 50, each with id, title, caseNumber, court, decisionDate as YYYY-MM-DD, caseType and an isCurrent boolean. Timeline entries carry date, a status of either Final Judgment or Hearings / Orders, a statusLabel which prefers the disposal nature where present, and documentId. Timeline entries are produced only for documents that have both a date and a stored PDF, so a short timeline is a statement about stored documents rather than about the matter's history. Where the case has no case number, both arrays come back empty with an explanatory message, which is the honest response and much better than a fabricated single entry.

    What Is Deliberately Never Returned

    A well designed response is defined as much by its omissions as by its contents. Several categories are excluded on purpose.

    Storage keys, bucket names and local paths

    Internal object storage locations never appear in a response. Exposing them turns an authorisation model into a guessing game and hands an attacker a map of your infrastructure. The presigned, expiring pdfUrl exists precisely so that the underlying location never has to be revealed.

    Raw full text

    The detail endpoint returns structured fields, not the entire document dumped into JSON. This keeps responses small enough to be usable at scale, and it means the value returned is the structure, which is the expensive part to produce.

    Embedding vectors

    The vectors backing semantic search are not in the payload. They are large, meaningless to a consuming application, and they are the derived asset the retrieval system is built on. Semantic results carry a score, which is the part you can actually act on.

    Internal model and version tracking

    Which model produced which field, at what internal version, on what pipeline run. Exposing it invites clients to build logic on operational details that will change without notice. analysisSchemaVersion is deliberately published; the rest is not.

    Two protective mechanisms are visible in the payload rather than hidden. Text fields in the case detail and analysis responses are watermarked per API key owner, so text that leaks can be traced back to the account it came from. And sensitive values are sanitised on the logging side: every authenticated call is recorded per key, but authorization, cookie, x-api-key, password, token and secret values are redacted before storage, so your credential never sits in a log even when your own client accidentally echoes it.

    Field sanitisation and watermarking are not friction. They are the reason a court data provider can hand you structured text at all, and they are what you should look for when assessing whether a vendor has thought about what happens after the response leaves their server.

    Three Modelling Rules That Save a Migration

    Everything above reduces to three decisions, all of which are cheap on day one and painful on day ninety.

    1

    Treat every field as optional

    Every field except the identifier you searched by. Not most fields, every field. Nullable types throughout, no non-null assertions, no defaulting a missing summary to an empty string that later renders as a blank paragraph in a client facing document. Metadata completeness varies by court and by year: District Court records are thinner than High Court records, and older records are thinner than recent ones everywhere. Your schema should be able to represent a record that is a title, a number and nothing else, because such records exist and are legitimate.

    2

    Never key your database on a display case number

    Key on the internal id. Index caseNumber and cnr as secondary lookups. A case number is a composite of case type, serial number and year issued by one registry, and it repeats across courts and across years. The bug it produces is not a crash but a conflation: two unrelated matters merged into one row, both looking entirely plausible, discovered by a user rather than by a test.

    3

    Store the raw payload alongside your parsed row

    Persist the complete JSON response in a column next to your normalised fields. It costs storage and it buys you the ability to reparse from your own store when you discover a field you did not map, rather than refetching hundreds of thousands of records against a 10 requests per minute budget. It also gives you an audit trail: when a user disputes what your product showed them, you can prove what the API actually returned on the day you fetched it.

    The failure that reaches your users

    The dangerous parsing bug is not the exception. An exception surfaces in your error tracker and gets fixed. The dangerous one is the field that silently becomes an empty string, flows into a generated document, and reaches a client as a confident blank where the disposal nature should have been. Absent data must remain visibly absent all the way to the screen. A user who can see that a field was never published makes a good decision. A user shown a blank they believe is data makes a bad one.

    Reading a Response Well

    The habit that separates a robust integration from a fragile one is treating the response as testimony rather than as truth. The payload tells you what the registry published, filtered through what the pipeline could extract, as of when it was fetched. Every one of those qualifications is a real limit on what you can assert to a user.

    So surface the qualifications rather than hiding them. Show the fetch timestamp on any status. Show hasAnalysis rather than an empty analysis panel that implies nothing was found. Show which fields came back empty instead of quietly collapsing the layout so the user cannot tell the difference between a matter with no listed judges and a matter whose registry publishes judge names inconsistently. The registry remains the authority, and anything aggregated is a view of what registries published. Products that say so are more useful, not less, because a user who understands the boundary can work confidently inside it.

    Get the wrapper handling right once, key on the internal id, keep the raw payload, and treat every field as optional, and the rest of the integration is ordinary engineering. Get any one of them wrong and you will find out on a Tuesday, from a user, about a District Court record from 2012. The full field reference for every endpoint, including which responses carry which envelope, is at the API documentation, and there is an API overview.

    Model the payload before you build on it

    A case law response is a report on what a registry published, not a row from a table, and modelling it as the latter is how integrations break in week three. Treat every field as optional, key on the internal id rather than a display case number, and keep the raw payload beside your parsed row. The CourtMesh API uses one success and error envelope across all twelve endpoints, returns structured case records over roughly 310 million cases from official government portals, and documents every field, including the ones that are frequently absent, at the API documentation. Overview and access on the API page.

    Explore CourtMesh
    APIJSONCase LawDevelopersData Modeling
    X LinkedIn