LexAtlas
API v1 · integration guide

Wire your service to the corpus

Every endpoint we serve, with request examples and real responses — the same interface our own products run on. If you are integrating a legal assistant, a compliance tool or a research product, this page is the contract in human form; the machine-readable one ships with your access as OpenAPI.

Access model

The API is not open to the public internet. Two things exist, and they are different on purpose:

WhatWho can reach itWhat it is for
/api/screen-demo on this site anyone, 10 requests/min, limit=3 fixed a public demo of sanctions screening — one handle, trimmed output
the full /v1/* surface contracted integrators only everything on this page

Integrators reach the API the way our own assistant product does: we provision a dedicated tunnel onto your host, and your service talks to http://127.0.0.1:8788 as if the corpus were local. Nothing of yours is exposed to the internet, and nothing of ours is either — the database never leaves our box.

# what we ship you (systemd, one command to install)
lexatlas-tunnel@yourservice.service        # the tunnel itself, auto-restart
lexatlas-tunnel-check@yourservice.timer    # liveness check: is the socket answering, not just running

# after install, from your host:
curl -s -H "X-API-Key: $LEXATLAS_KEY" http://127.0.0.1:8788/v1/health

Ask for access through Contact. You receive: an API key, the tunnel kit, your jurisdiction scope, and the OpenAPI document at /openapi.json (/docs serves it as a page).

Base URL in every example below: http://127.0.0.1:8788 — your end of the tunnel. Legacy short paths (/get, /search, …) still answer, but write new integrations against /v1/*: that is the versioned surface we hold stable.

Authentication

One header. No OAuth dance, no session.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/norm/ru:gk_rf_1:395"

Two endpoints answer without a key, so a broken key is diagnosable: /v1/health and /v1/errors.

Rate limits are a bucket, not a calendar minute

curl -s -H "X-API-Key: $LEXATLAS_KEY" http://127.0.0.1:8788/v1/limits
{
  "key_name": "your-service",
  "rate_per_min": 300,
  "active": true,
  "org": { "slug": "your-org", "name": "Your Company" },
  "remaining_this_minute": 296,
  "batch": {
    "norms_max_cites": 50,
    "citecheck_batch_max_documents": 20,
    "answer_pack_max_fragments": 20,
    "changes_max_limit": 1000,
    "max_body_bytes": 1048576
  },
  "rate_model": {
    "kind": "token_bucket",
    "refill_per_min": 300,
    "burst": 300
  },
  "on_limit": "HTTP 429 + Retry-After (seconds until the next token, not until the minute ends)"
}

The window is counted per key, not per host. If you run several workers, they share the bucket. On 429, honour Retry-After — it is the wait for the next token, so sleeping to the top of the minute is both wrong and slower.

The trust envelope

Every answer that carries legal text carries, in the same response, where it came from, whether it is in force, whether you may quote it verbatim, and how confident we are. This is not decoration: a legal answer without provenance cannot be defended in a dispute, and vendor due-diligence will ask you for exactly these fields.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/norm/ru:gk_rf_1:395"
{
  "cite": "ru:gk_rf_1:395",
  "title": "Ответственность за неисполнение денежного обязательства",
  "act_title": "Кодекс Российской Федерации от 30.11.1994 № 51-ФЗ «Гражданский кодекс …»",
  "status": {
    "value": "in_force",
    "operative": true,
    "confirmed": true,
    "basis": "source_status",
    "evidence": "статус акта у источника: «Действует c изменениями»"
  },
  "language": {
    "lang": "rus",
    "status": "authentic",
    "authentic": true,
    "note": "язык принятия — текст имеет юридическую силу"
  },
  "confidence": "official_consolidated",
  "provenance": {
    "source": "pravo.gov.ru/ips",
    "official_url": "http://pravo.gov.ru/proxy/ips/?docbody=&nd=102033239",
    "license": { "reuse": "reconstruct", "regime": "public_domain", "quote_verbatim_allowed": true }
  },
  "knowledge_cursor": 1691710
}
FieldRead it as
status.valuein_force · repealed · suspended · not_yet_in_force. basis tells you how we know; confirmed:false means the state is inferred, not stated by the source.
language.authenticwhether this text is the language of adoption. Verbatim quoting, point-in-time and sanctions work only on authentic text; translations are a marked layer, never a substitute.
confidencehow the text was obtained: official_consolidated is the strongest, reconstructions are labelled as such.
provenance.license.reuseredistribute · reconstruct · cite_only · hold. Your redistribution rights follow this field, per act.
knowledge_cursorthe corpus clock this answer was computed against — see below.

Time, cursors and point-in-time

Two different clocks, and confusing them is the classic integration bug:

# the corpus clock right now
curl -s -H "X-API-Key: $LEXATLAS_KEY" http://127.0.0.1:8788/v1/knowledge
{ "at": "2026-08-20T20:38:50Z", "changes_cursor": 1691710 }

# the rule as it stood on a past date
curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/norm/ru:gk_rf_1:395?as_of=2019-01-01"

Store the cursor with anything you cache. When you re-check, ask /v1/changes?cursor=<stored> and you get exactly what happened since — no polling of whole documents, no diffing on your side.

Retrieval

GET/v1/norm/{cite}

One article: text, act, status, freshness, provenance. Parameters: lang, as_of.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/norm/ru:gk_rf_1:395?lang=rus"

The cite format is jurisdiction:act_code:article. Act codes are ours and have changed before — if you store anything long-term, store the requisites and resolve through /v1/act instead.

POST/v1/norms

Up to 50 cites in one call — use it instead of a loop; it is one rate-limit token, not fifty.

curl -s -X POST -H "X-API-Key: $LEXATLAS_KEY" -H "Content-Type: application/json" \
  -d '{"cites":["ru:gk_rf_1:395","ru:gk_rf_1:196"],"lang":"rus"}' \
  http://127.0.0.1:8788/v1/norms

GET/v1/verify/{cite}

The cheap question: does this citation exist, is it in force, may it be quoted verbatim?

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/verify/ru:gk_rf_1:196"
{ "cite": "ru:gk_rf_1:196", "exists": true, "in_force": true, "status": "in_force" }

GET/v1/history/{cite}

Amendment history of an article: when it changed and by which law. This is the layer that cannot be reconstructed after the fact — it exists because we recorded it as it happened.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/history/ru:gk_rf_1:395"

GET/v1/act · GET/v1/act/toc

Resolve an act by its requisites (date + number), then list its structure. Requisites do not age; codes do.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/act?jur=ru&date=1994-11-30&no=51-%D0%A4%D0%97"
{
  "jurisdiction": "ru",
  "query": { "date": "1994-11-30", "number": "51-ФЗ", "number_matched_as": "51" },
  "found": true,
  "count": 3,
  "acts": [
    { "cite_prefix": "ru:gk_rf_1", "is_canonical": true, "in_force": true,
      "kind": "codex", "text_available": true, "articles": 594, "articles_indexed": 594 }
  ]
}

GET/v1/search

Hybrid retrieval — full-text and vector channels fused by RRF, across jurisdictions. Parameters: q, jur, k, plus act to restrict to a single act.

curl -s -H "X-API-Key: $LEXATLAS_KEY" -G \
  --data-urlencode "q=ответственность за неисполнение денежного обязательства" \
  --data "jur=ru&k=5" \
  http://127.0.0.1:8788/v1/search

Each row carries cite, act_code, act_title, act_kind, status, language, signal (which channels matched) and rrf. Two response fields are worth wiring into your code from day one:

  • act_filter.known — present always when you filter by act. An unknown act code returns an empty list with known:false: that is a fact about our corpus, not about the law.
  • situation_used — present when the question matched a verified institution mapping and we therefore asked our index a different, shorter question. Your text is never rewritten; match tells you whether it was recognised exactly or generalised.

POST/v1/answer_pack

Everything an answer needs in one call: ranked fragments, mandatory citations, the outcome verdict and the envelope. This is what a legal assistant should call instead of assembling five requests.

curl -s -X POST -H "X-API-Key: $LEXATLAS_KEY" -H "Content-Type: application/json" \
  -d '{"q":"оплата товара по договору поставки просрочка оплаты покупателем","jur":"ru","k":8}' \
  http://127.0.0.1:8788/v1/answer_pack
{
  "outcome": "supported",
  "situation_used": { "primary": "оплата товара покупателем", "match": "exact" },
  "must_cite": [ { "cite": "ru:gk_rf_2:486" }, { "cite": "ru:gk_rf_2:516" }, { "cite": "ru:gk_rf_1:395" } ],
  "fragments": [
    { "cite_base": "ru:gk_rf_2:486", "relevance": { "grade": "strong" } },
    { "cite_base": "ru:gk_rf_2:516", "relevance": { "grade": "companion" }, "companion_of": "расчёты за поставляемые товары" }
  ]
}

Read relevance.grade as a closed enumeration and treat an unknown value as a refusal. strong means the text lexically confirms the question; companion means the row was pulled in as the neighbouring institution and is not lexical confirmation. Products that read only "is it strong?" have mis-reported companions as confirmed — that exact defect was found and fixed on 19–20 August 2026.

Change & calendar

GET/v1/changes

The changefeed. Ask by cursor (recommended) or by since date; jur and limit narrow it.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/changes?cursor=1691000&jur=ru&limit=2"
{
  "since_cursor": 1691000,
  "summary": { "article_added": 1269587, "article_modified": 300006, "article_removed": 17798 },
  "cause_summary": { "source_change": 1384240, "artifact_fix": 136758, "reparse": 66845 },
  "cause_note": "source_change — the law itself changed; any other cause is OUR correction of the
                 same original text. If you only want legal changes, filter on `cause`.",
  "items": [ { "cite": "ru:…", "change": "article_modified", "at": "…", "cursor": 33910 } ],
  "order": "cursor_asc",
  "page": { "limit": 2, "returned": 2, "truncated": true }
}

Filter on cause. Most integrations want source_change only — otherwise our own re-parsing shows up in your product as "the law changed", which it did not.

GET/v1/calendar/upcoming · GET/v1/calendar/stats

What comes into force next, with the sentence that says so. A predictive fact, quoted, not inferred.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/calendar/upcoming?jur=ru&months=3"
{
  "from": "2026-08-20", "to": "2026-11-19", "jur": "ru", "count": 544,
  "events": [
    { "date": "2026-09-01", "cite": "ru:ru_fz105_2026:3",
      "snippet": "Настоящий Федеральный закон вступает в силу с 1 сентября 2026 года." }
  ]
}

Coverage & trust

GET/v1/coverage/{jur}

Provable completeness, not a marketing number: the source's own count, our count inside the same slice, and the evidence URL the count was read from.

curl -s -H "X-API-Key: $LEXATLAS_KEY" http://127.0.0.1:8788/v1/coverage/kz
{
  "jurisdiction": "kz",
  "known": true,
  "universe": [ { "doc_type": "документы", "source_total": 229237, "our_total": 228271,
                  "measured_at": "2026-08-20", "evidence": "https://adilet.zan.kz/… → regex" } ],
  "catalog": { "rows": 228271, "with_source_status": 228244, "pct_of_universe": 99.6 },
  "corpus": { "acts": 74, "articles": 19821, "articles_indexed": 19821 }
}

catalog is what we have catalogued; corpus is what we have with full text and index. They are different numbers and we never merge them.

GET/v1/transparency/{jur} · GET/v1/authority/{jur} · GET/v1/observed/{jur} · GET/v1/brief/{jur}

The observation layer around the corpus:

  • transparency — publication lag and how much of a jurisdiction is machine-observable;
  • authority — the state body map and who is the official publisher for each act type;
  • observed — what our observation points actually saw (issues, acts, bills), before parsing;
  • brief — the daily country brief in three planes: legislation, media, pipeline.
curl -s -H "X-API-Key: $LEXATLAS_KEY" "http://127.0.0.1:8788/v1/brief/kz?date=2026-08-20"

null in these responses means "not measured", never zero. We keep the two apart deliberately: a threshold that treats "unknown" as "none" turns silence into a false claim.

Compliance

GET/v1/screen

Multipolar sanctions screening — US OFAC, UK OFSI and EAEU lists, matched by name, alias or registration number, quoted verbatim from the list.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/screen?q=Rosneft&limit=1"
{
  "query": "Rosneft", "hits": 1, "total_matches": 6, "truncated": true,
  "by_jurisdiction": { "us": 1 },
  "matches": [
    { "jurisdiction": "us", "authority": "us_ofac",
      "list_entry": "OPEN JOINT-STOCK COMPANY ROSNEFT OIL COMPANY",
      "subject_type": "entity", "status": "in_force",
      "restriction_kind": "financial_designation",
      "match_via": "alias_exact", "match_score": 1.0,
      "legal_basis": "UKRAINE-EO13662, RUSSIA-E…" }
  ]
}

No hit is not a clean bill. The response says which lists were screened (screened_lists) and states this explicitly. Screening a name we do not carry produces an honest empty answer, not a negative finding.

POST/v1/citecheck · POST/v1/citecheck/batch

Give it a document; it finds every legal reference in the text and checks each one against the corpus: does it exist, is it still in force, was it renumbered. Batch takes up to 20 documents.

curl -s -X POST -H "X-API-Key: $LEXATLAS_KEY" -H "Content-Type: application/json" \
  -d '{"text":"Согласно ст. 395 ГК РФ проценты начисляются на сумму долга.","default_jur":"ru"}' \
  http://127.0.0.1:8788/v1/citecheck
{
  "total": 1,
  "tally": { "ok": 1, "repealed": 0, "not_found": 0, "unresolved": 0 },
  "flagged": 0,
  "findings": [
    { "raw": "ст.395 ГК РФ", "article_no": "395", "jurisdiction": "ru",
      "code_abbrev": "ГК", "cite": "ru:gk_rf_1:395", "verdict": "ok" }
  ]
}

This is the cheapest integration with the highest visible value: run it over drafts your product already produces and you catch dead references before your user signs them.

Domain feeds

GET/v1/pipeline

The legislative pipeline across the CIS: bills and laws by stage, with links to the resulting act when it appears. Parameters: jur, stage, since, linked_only, limit.

curl -s -H "X-API-Key: $LEXATLAS_KEY" "http://127.0.0.1:8788/v1/pipeline?jur=ru&limit=2"

GET/v1/notary/tariffs

Regional notary tariffs of the Russian Federation, by subject and scope, with the approving document behind each figure. Parameters: subject, scope, changed_since, limit.

curl -s -H "X-API-Key: $LEXATLAS_KEY" \
  "http://127.0.0.1:8788/v1/notary/tariffs?subject=77&scope=all"

GET/v1/rates

Central bank policy rates — the value that was in force on a given date, plus the full history of change points. A policy rate is an observation with a date, not a norm: it is set by a board decision, so it has no articles and no editions. What we add is not the number — anyone can read that off the central bank site — but the basis on which it applies, returned with a verbatim quote from the law.

as_of is the point of this endpoint: an interest claim under art. 395 of the Russian Civil Code needs the rate of the period of default, not today's. in_force_to is derived from the next change point; null means "no end yet", not "forever". An as_of before the first known point returns found: false and says where the series starts — returning the earliest value instead would pass "we do not know" off as "that is what it was". Parameters: jur, series, as_of, history.

curl -s -H "X-API-Key: $LEXATLAS_KEY"   "http://127.0.0.1:8788/v1/rates?jur=ru&as_of=2022-03-15"
{
  "jurisdiction": "ru",
  "found": true,
  "rates": [
    {
      "id": "ru_key_rate",
      "title": "Ключевая ставка Банка России",
      "unit": "% годовых",
      "as_of": "2022-03-15",
      "value": "20.00",
      "in_force_from": "2022-02-28",
      "in_force_to": "2022-04-10",
      "is_current": false,
      "agency": { "short_name": "ЦБ РФ", "url": "https://www.cbr.ru/" },
      "basis": {
        "act": "ru:fz86_rf",
        "article": "37",
        "verbatim": "Банк России может устанавливать одну или несколько процентных ставок…"
      },
      "coverage": {
        "first_change_point": "2013-09-17",
        "last_change_point": "2026-07-27",
        "change_points": 66
      },
      "license": "unknown"
    }
  ],
  "knowledge": { "at": "2026-08-30T01:52:10Z", "changes_cursor": 1812164 }
}

Source licence is unknown until we review the terms of use of the source — the field ships with every row so that it does not surface at procurement time.

Account & ops

EndpointWhat it answers
GET/v1/healthservice state and the full endpoint list — no key required, so you can tell "key is wrong" from "service is down"
GET/v1/errorsthe error contract itself, served by the API — no key required
GET/v1/limitsyour rate, your batch ceilings, your jurisdiction scope
GET/v1/usageyour own consumption — the same numbers we bill by. Optional client_tag lets you split usage per internal tool; scope is your organisation's keys only
GET/v1/accountkeys of your organisation, their scopes and state
POST/v1/account/keysmint a key (shown once)
POST/v1/account/keys/{id}/rotaterotate — old key stays valid for the grace window
POST/v1/account/keys/{id}/revokerevoke immediately

by_client_tag names the caller, not the tenant it served. We store the X-Client-Tag header exactly as you send it, once per request, and we do not infer anything from it. That makes the breakdown exact for request-driven endpoints — /v1/search, /v1/answer_pack, /v1/norms, /v1/act — where one call serves one tenant.

It does not hold wherever your side collapses many tenants into one call — a change-feed sync or cache refresh that runs once per process, a nightly batch, a warmed index. There the request carries the tag of whichever tenant happened to trigger it, so the row reads as if that tenant made every call, and a per-department bill built on it will be plausible and wrong. Whether that applies to you is a fact about your client, not about this API: for each endpoint you bill on, check whether the call that reaches us is made per tenant or once for many. Where it is shared, send a tag that names the job rather than the tenant that woke it, and read that row as a per-process cost.

Two-way exchange — how our own consumer is wired

An integration is not finished when data flows one way. Our assistant product runs against these two feeds, and we recommend the same shape to every integrator: it turns "we think the corpus is wrong" into a reproducible, machine-tracked conversation.

GET/v1/deploys?since=<cursor>

Our deployment journal. Every change we ship that can affect your answers appears here with a monotonic cursor, so your regression suite can start itself when — and only when — something moved.

curl -s -H "X-API-Key: $LEXATLAS_KEY" "http://127.0.0.1:8788/v1/deploys?since=108"
{
  "deploys": [
    { "cursor": 110,
      "deployed_at": "2026-08-20T19:19:02Z",
      "closes": [ { "id": "P-2026-08-19-038",
                    "check": "ops/test_situation_layer.py + ops/check_enumerations.py",
                    "available_via_api": true } ],
      "retracts": [],
      "note": "…what changed, in plain words…" }
  ]
}
  • closes is always present, empty list included — "nothing closed" and "field missing" are different facts;
  • available_via_api sits on each identifier: "fixed" and "reachable by you" are separate events, and the difference once cost a consumer a day of downtime;
  • retracts carries statements we withdraw — knowledge that travelled by machine has its cancellation travel the same way.

POST/v1/findings · GET/v1/findings?since=<cursor>

Send us a defect with the same key you read with. We triage it hourly, automatically.

curl -s -X POST -H "X-API-Key: $LEXATLAS_KEY" -H "Content-Type: application/json" \
  -d '{
        "finding": {
          "id": "YOURPREFIX-2026-08-20-001",
          "blocking": false,
          "title": "expected norm does not reach top-5 for a supply-contract question",
          "class": "corpus.rank.norm_not_reached"
        },
        "cases": [
          { "task": "T001",
            "corpus_query": "оплата товара по договору поставки просрочка оплаты покупателем",
            "jur": "ru",
            "expected": ["ru:gk_rf_2:486", "ru:gk_rf_2:516"],
            "observed": ["ru:gk_rf_2:489"] }
        ]
      }' \
  http://127.0.0.1:8788/v1/findings

Three fields are mandatory, and the reason is the same for all three — a finding we cannot reproduce by machine is a finding we cannot close:

  1. finding.idyour identifier. We close it under your number, not ours;
  2. finding.blocking — boolean, explicitly;
  3. cases[].corpus_queryverbatim, exactly the string you sent us.

Then read the state back with GET /v1/findings?since=<cursor>: statuses move through полученовоспроизведенозакрыто, and the closing deploy appears in /v1/deploys with your id in closes.

Errors & enumerations

curl -s http://127.0.0.1:8788/v1/errors   # no key needed: the contract describes itself
CodeMeaningWhat to do
400malformed request — the body says which field and whyfix the request; the message is machine-readable (error_code)
401key missing or invalidcheck the header; /v1/health answers without a key
404route does not existnote: an unknown act code is not a 404 — it is a 200 with act_filter.known:false
429rate limitsleep Retry-After seconds
5xxour faultretry with backoff; if it persists, send a finding

Treat every enumeration as closed, and an unknown value as a refusal — never as success. We extend enumerations only with an announcement in the deploy journal first, and we guard that with a static check on our side. Your side should fail loudly rather than guess what a new value means.

Integration checklist

What we would verify before calling an integration finished — this is the list our own consumer went through:

  1. Tunnel liveness is checked, not assumed. A running unit is not an answering socket. Ship the timer, not just the service.
  2. Every stored answer keeps its knowledge_cursor. Without it you cannot tell a stale cache from a changed law.
  3. Your refresh reads /v1/changes?cursor= and filters on cause=source_change.
  4. Your regression suite is triggered by /v1/deploys, and the cursor you last processed is persisted. A deploy without a journal line does not exist for you — and we consider such a deploy unshipped.
  5. Enumerations are closed on your side (status, relevance.grade, cause, outcome), unknown value ⇒ refusal.
  6. Verbatim gate. Quote only authentic-language text, and only where license.quote_verbatim_allowed is true.
  7. Defects go through POST /v1/findings with a verbatim corpus_query, not through prose. Prose is welcome too — it just cannot be replayed.
  8. Rate handling honours Retry-After and shares the bucket across workers.

Limits & licensing

Need access, a wider scope, or a review of your integration plan? Talk to us — we will send the tunnel kit, a key and the OpenAPI document.