On this page
Documentation·API v1·Read time ~ 12 min

Documentation

A complete, plainspoken reference. Every endpoint lists its exact request shape, response shape, and the error codes it can return.


LexAPI is a thin, opinionated REST layer over the European Union's public legal corpus — EUR-Lex, the Court of Justice (CJEU) docket, and the Official Journal. We mirror the source records, normalize them to a stable JSON schema, expose the citation graph as a first-class index, and serve everything from EU-region infrastructure with a single API key. The base URL is https://lex-api.com and every endpoint lives under /api/v1/.

Quickstart

Three calls is enough to know whether LexAPI fits your project.

terminal — quickstart
# Set your API key (create one at https://lex-api.com/dashboard)
$ export LEX_KEY="lex_..."

# 1. fetch a regulation
$ curl -X POST "https://lex-api.com/api/v1/documentContent" \
   -H "x-api-key: $LEX_KEY" \
   -H "Content-Type: application/json" \
   -d '{"celexNumber":"32016R0679"}'

# 2. full-text search
$ curl -X POST "https://lex-api.com/api/v1/search" \
   -H "x-api-key: $LEX_KEY" \
   -H "Content-Type: application/json" \
   -d '{"query":"GDPR data protection"}'

# 3. get a document by URL
$ curl -X POST "https://lex-api.com/api/v1/documents/url" \
   -H "x-api-key: $LEX_KEY" \
   -H "Content-Type: application/json" \
   -d '{"url":"https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32016R0679"}'

Authentication

Every request takes an API key via the x-api-key header. Keys are issued from your dashboard, prefixed lex_, and can be rotated or revoked at any time. Public keys are not supported — your token does not belong in client-side code.

HeaderRequiredNotes
x-api-keyyesYour API key from the dashboard
Content-Typeyes (POST/PUT)application/json
AcceptnoDefaults to application/json

Plans & limits

LexAPI is billed on a monthly credit pool. Each request draws credits by operation (see What each call costsbelow); when the pool is empty, metered calls return 402 until your monthly reset or a top-up. Limits are enforced server-side and reflected in every response under subscription, usage, and credits.

TierPriceCredits / monthRate / minMax pagesBatch sizeWebhooks
FREE€050015110
STARTER€15/mo30,00060552
PROFESSIONAL€39/mo150,000120202010
BUSINESS€99/mo500,0003001005050
ENTERPRISEcustomcustomcustom1005050

Annual billing is ~20% off (Starter €12/mo, Professional €31/mo, Business €79/mo, billed yearly). Credits reset monthly and do not roll over. Need more mid-cycle? Buy a top-up pack — +10,000 for €10, +50,000 for €40, or +200,000 for €120. Top-up credits are spent after your monthly allowance and never expire.

What each call costs

CreditsOperations
0/info — always free
1Resolve, cites / cited-by, version history reads; per row exported. Also the settled price of any document read served from LexAPI's own corpus (see the 5-credit row)
3 / pageKeyword search and recent documents — every result page is fetched live from EUR-Lex, or served from a 60-minute result cache when an identical keyword search was just answered (X-Search-Cache: hit; same price). Charged upfront for the pages you request (maxPages, tier-capped), auto-refunded for pages never fetched
4The settled price of a document read answered by the Publications Office Cellar API — cellar on document fetch / metadata / by-URL, cellar-oj on document content and by-URL only — no browser fetch, but a real, quota-limited upstream call
5Document fetch / metadata / by-URL (and per item in a batch), and citation extraction — the live-fetch price; auto-refunded to 4 when the Publications Office Cellar API answered, to 1 when served from our own corpus, and to 1 when citations were already extracted (X-Source: corpus|cellar)
5Semantic search (case law & legislation)
15Semantic case-law search with hyde: true (LLM query rewriting; auto-refunded to 5 if rewriting falls back)
20Citation network / graph (network, path, related, stats)

Why the split: keyword search and cold document fetches run a real browser session against EUR-Lex, which is far more expensive to serve than reads from our own corpus — pricing follows that cost, in three tiers. A document read settles at 1 when it never leaves our database, 4 when the Publications Office's Cellar API served it (its daily call quota is finite and shared with our ingestion pipeline), and 5 for a browser fetch of EUR-Lex itself. In practice the corpus already holds the documents most integrations touch, so the typical document read still settles at 1 credit.

Failed requests refund automatically. Requests rejected before any upstream work — validation errors (400), plan/tier gates (403) — refund in full. A 404 (the document genuinely doesn't exist on EUR-Lex) refunds down to 1 credit: the lookup ran, and the authoritative "does not exist" answer costs the same as a corpus read. Server failures (5xx) refund in full. Refunds apply just after the response is sent, so the failing response's credits figures may still show the pre-refund pool; your next call reflects it.

Requests over a per-minute rate limit return 429 with a retryAfter (seconds). When your monthly credit pool is exhausted, metered calls return 402 with a credits breakdown and resetsAt. Asking for more maxPages or a larger batch than your tier allows is silently capped with an X-Warning header; you still get a 200, and you're only charged for the capped amount actually processed.

Existing customers: accounts created before the credit-pricing launch keep their original daily-call plan — those responses report usage against a daily limit with subscription.pricingMode: "daily" and no credits object. Nothing about your plan changes.

Errors & retries

HTTP status codes follow the conventional grammar. The body is always JSON, with a stable error field for programmatic handling and a human-readable message.

429 Too Many Requestsapplication/json
{
  "error": "Rate limit exceeded",
  "message": "You're sending requests faster than your plan allows",
  "retryAfter": 45
}
402 Payment Required — credit pool exhaustedapplication/json
{
  "success": false,
  "error": {
    "code": "CREDITS_EXHAUSTED",
    "message": "Credit pool exhausted",
    "details": {
      "operation": "semantic_case_law", "weight": 5,
      "available": 2, "included": 30000, "topup": 0,
      "used": 29998, "resetsAt": "2026-07-01T00:00:00.000Z"
    }
  }
}
StatusMeaning
400Bad request — invalid parameters or missing required fields
401Missing or invalid API key (or key has been deactivated)
402Monthly credit pool exhausted — buy a top-up or wait for the reset. Carries a credits/details breakdown with resetsAt
403No active subscription, or FREE tier on a paid-only endpoint
404Document or resource not found
429Per-minute rate limit exceeded (or, on legacy daily plans, the daily quota) — retry with backoff
5xxServer error or upstream timeout — retry with exponential backoff

Changelog

User-visible API changes — new endpoints, pricing, and behavior fixes — are tracked in the changelog, grouped by month. The full error catalog, rate-limit headers, and webhook signature guide live in the Markdown reference.

Python SDK

The official Python client — hand-written, dependency-light (httpx only), with sync and async clients, retries that honor Retry-After, typed exceptions for every error code, and credit visibility on every response. PyPI · GitHub

terminal
pip install lexapi-client

The distribution is lexapi-client; the import is lexapi:

python
from lexapi import LexAPI, CreditsExhaustedError

client = LexAPI()  # reads LEXAPI_API_KEY from the environment

doc = client.get_document("32016R0679").document
print(doc.title)

resp = client.semantic_search("credit scoring article 22", hyde=True)
print(resp.hyde, [hit.case_name for hit in resp.results[:3]])

as_of = client.get_document_at_date("32016R0679", "2026-06-15")
print(as_of.document.version)

An AsyncLexAPI mirrors every method as a coroutine. Bulk export streams as an iterator; batch, citations, webhooks, and point-in-time history are all first-class. The endpoint matrix and the SDK contract (retry policy, error taxonomy) live in the repo’s PLAN.md.

TypeScript SDK

The official TypeScript client — zero runtime dependencies, strict types hand-curated from the OpenAPI spec, ESM + CJS, and the same retry/error/credits contract as the Python SDK. Server-side only: API keys are secrets and must not ship in browser code. npm · GitHub

terminal
npm install @lexapi/client
typescript
import { LexAPI, CreditsExhaustedError } from "@lexapi/client";

const client = new LexAPI(); // reads LEXAPI_API_KEY

const doc = await client.getDocument({ celexNumber: "32016R0679" });

const results = await client.semanticSearch({
  query: "credit scoring article 22",
  hyde: true,
});

const stream = await client.export({ documentType: "regulation" });
for await (const row of stream) {
  index(row); // streaming NDJSON export (Business tier)
}

Errors are typed classes you can branch on — RateLimitedError carries retryAfter, CreditsExhaustedError carries resetsAt — and truncation/partial signals surface as response fields, never silently.

MCP server (Claude, Cursor, any MCP client)

@lexapi/mcp exposes the API as ten read-only tools over the Model Context Protocol — search, document content, metadata, recent documents, the citation graph, and semantic search — so Claude (or any stdio MCP client) can research EU law natively with real citations. npm

Claude Code:

terminal
claude mcp add lexapi --env LEXAPI_API_KEY=lex_your_key -- npx -y @lexapi/mcp

Claude Desktop (claude_desktop_config.json):

json
{
  "mcpServers": {
    "lexapi": {
      "command": "npx",
      "args": ["-y", "@lexapi/mcp"],
      "env": { "LEXAPI_API_KEY": "lex_your_key" }
    }
  }
}

Tools: lex_search, lex_get_document, lex_get_metadata, lex_get_document_by_url, lex_recent_documents, lex_cited_by, lex_cites, lex_citation_network, lex_semantic_case_law, lex_semantic_legislation. Calls are billed exactly like the REST endpoints they wrap; the free tier’s 500 monthly credits are plenty to evaluate every tool.

Corpus coverage

LexAPI doesn't just proxy EUR-Lex — we maintain our own mirror of the EU legal corpus in EU-region Postgres, with a permanent version history. Most reads (/documentContent, /documents/metadata) are served from the corpus and return in under 100ms. Cold misses fall back to a live EUR-Lex scrape, which then seeds the corpus for the next caller.

What's indexed

DimensionCoverage
DomainEU_LAW (all subdomains: legislation, case law, treaties, opinions, communications, …)
Date range2010 → present, with ongoing hourly delta
LanguageEnglish is warm-indexed. The other 23 official EU languages are fetched on demand via the language parameter and cached per (CELEX, language) on first request.
Document count~21,000 documents and growing
Citation graph~65,000 edges across reference, amendment, repeal, implementation, legal-basis, proposal
Refresh latencyHourly delta worker — new EUR-Lex publications appear in the corpus within ~1 hour of upstream publication

What's stored per document

  • Parsed metadata: title, document type, author, dates, ECLI/ELI, keywords, EUROVOC descriptors
  • Parsed content: full text, structured articles (e.g. GDPR's 99 articles are individually addressable), tables, annexes, section headings
  • Raw HTML (metadata page + rendered page) for both the current version and every historical version
  • Citation edges to/from other documents in the corpus

Versioning

When a document's parsed legal text changes (e.g. an amendment is consolidated), we snapshot the previous version to document_versions and bump the live row's version. Metadata-only changes (added cross-references, etc.) refresh in place without bumping, and changes confined to EUR-Lex page chrome never create versions. X-Corpus-Version and X-Corpus-FetchedAt response headers tell you which version you got.

How to tell whether your response came from the corpus

response headers
X-Source: corpus    # served from our DB (sub-100ms typically)
X-Source: cellar    # cold miss answered by the EU Publications Office Cellar API (no browser)
X-Source: cellar-oj # text extracted from the Official Journal issue that carries it (no browser)
X-Source: live      # cold miss — fetched fresh from EUR-Lex by browser (~1-3s), now also in corpus
X-Corpus-Version: 2
X-Corpus-FetchedAt: 2026-05-12T07:57:48.313Z

Each source settles at its own price: corpus reads bill 1 credit, Cellar-served reads (including cellar-oj) bill 4 — a real, quota-limited call to the Publications Office — and only a browser fetch of EUR-Lex itself pays the full 5. Document reads may also carry an X-Cache header reflecting the internal fetch-cache state; treat it as informational.

Force a fresh fetch

If you need to bypass the corpus and force a fresh upstream fetch — served from Cellar when the rendition is available there, by live EUR-Lex scrape otherwise (e.g. you suspect a document was just updated and you want the absolute latest rendering before our delta worker has caught it) — pass bypassCorpus: true in the request body or as a query parameter. It skips both the stored corpus row and our short-lived HTML fetch cache, so you get a genuinely fresh read; it still prefers Cellar over the browser, because Cellar is an upstream source rather than a cache. Billed at the live rate:

bypass corpus
$ curl -X POST "https://lex-api.com/api/v1/documentContent" \
   -H "x-api-key: $LEX_KEY" \
   -d '{"celexNumber":"32016R0679","bypassCorpus":true}'

What's not in the corpus today

  • Documents predating 2010 (still accessible — they'll be live-fetched and added to the corpus on first request)
  • Non-EU national law and non-English language renderings (same fallback applies)
  • EUR-Lex search results without parseable CELEX identifiers (rare metadata-only listings)

Need historic coverage (pre-2010), other domains, or other languages indexed proactively? Contact [email protected] — backfill expansion is on a customer-driven roadmap.

CELEX numbers

Each EUR-Lex document has a CELEX identifier — a 10–12 character string that encodes its legal type, year, and sequence. The GDPR is 32016R0679; Schrems II is 62018CJ0311. CELEX numbers are case-insensitive on input — the API canonicalizes them to upper case in responses.

Languages

All twenty-four official EU languages are available. Pass language=en (default) for English; language=de for German, and so on. Supported codes: en, fr, de, es, it, pl, nl, pt, ro, bg, cs, da, el, et, fi, ga, hr, hu, lt, lv, mt, sk, sl, sv. On /documentContent and /documentContent/batch, language localizes the entire parsed document — both content.articles (title + content) and content.fullText — and falls back to English (with an X-Language-Fallback header) when a document is not published in that language on EUR-Lex.

Citation graph

The citation index is a first-class object. Every document tracks both directions: who it cites (cites) and who cites it (cited-by). Walk either direction with the /citations endpoints — or grab the full network in one call. Citations are extracted from EUR-Lex's metadata page and carry a citationType: reference, amendment, repeal, implementation, legal-basis, or proposal.

Webhooks & deltas

Subscribe to deltas: when new documents match your search criteria, we POST a signed payload to your URL. Deliveries carry an X-Webhook-Signature header (HMAC-SHA-256 of the body using your webhook secret) and an X-Webhook-ID. Failed deliveries retry with exponential backoff for 24 hours, and the delivery history is viewable from the dashboard or via GET /webhooks/:id/deliveries.


GET /api/v1/info

Returns the service catalog, your current subscription tier, your usage for the day, and the list of allowed values for filter parameters. Useful as a health check and for building dynamic UIs.

responseapplication/json
{
  "service": "LexAPI",
  "version": "2.0.0",
  "endpoints": { /* every endpoint with params */ },
  "subscription": {
    "tier": "PROFESSIONAL",
    "maxPages": 20,
    "maxBatchSize": 20,
    "maxWebhooks": 10,
    "pricingMode": "credits",
    "dailyLimit": null,
    "monthlyCredits": 150000,
    "rateLimit": "120 requests/minute"
  },
  "usage": { /* back-compat mirror of the pool */ },
  "credits": {
    "included": 150000,
    "topup": 0,
    "current": 1240,
    "limit": 150000,
    "remaining": 148760,
    "resetsAt": "2026-07-01T00:00:00.000Z"
  },
  "searchFilters": { /* allowed values for textScope, domain, documentType, author, procedure */ },
  "supportedLanguages": ["en", "fr", "de", /* … */]
}

Search EUR-Lex with user-friendly parameters (no URL building required).

Request body

request
{
  "query": "GDPR data protection",
  "textScope": "title-text",
  "dateFrom": "2020-01-01",
  "dateTo": "2024-12-31",
  "year": [2023, 2024],
  "documentType": ["judgment", "opinion"],
  "author": ["court-of-justice"],
  "procedure": "ordinary",
  "domain": "EU_LAW",
  "subdomain": "LEGISLATION",
  "language": "en",
  "maxPages": 2
}

Parameters

FieldTypeDescription
querystringSearch text. Optional if another filter is present — at least one of query, year, month, dateFrom/dateTo, documentType, author, procedure, domain, subdomain is required (400 otherwise). * (or only wildcard/quote characters) means no text constraint — a filter-only search
textScopestringtitle, text, title-text (default), or any
dateFrom · dateToYYYY-MM-DDDate range filter
year · monthint / listSpecific year(s) or month(s)
documentTypestring / listjudgment, directive, regulation, decision, communication, …
authorstring / listcommission, council, parliament, court-of-justice, general-court, …
procedurestring / listordinary, codecision, consent, consultation, …
domainstringEU_LAW, NATIONAL_LAW, ALL
subdomainstring / listLEGISLATION, CONSLEG, TREATIES, EU_CASE_LAW, …
languagestringTwo-letter language code (default: en)
maxPagesintResult pages to fetch (default: 1, capped by tier)
freshbooltrue bypasses the result cache and re-fetches from EUR-Lex (default: false)

Where a search is answered from. Keyword searches go to the EUR-Lex web service first (X-Search-Source: soap). If it is unavailable or its daily allowance is spent, a filter-only date window (no text, dateFromand dateTo, no documentType/author/procedure) is listed from the Publications Office Cellar (X-Search-Source: cellar, exact document-date range, one page billed); anything else is fetched from EUR-Lex result pages. If EUR-Lex itself is unreachable, a search the LexAPI corpus can answer faithfully is served from the corpus with partial: true, source: "corpus" andX-Search-Source: corpus: possibly incomplete, title-only text matching. If not even the corpus has a match you get the typed 504 TIMEOUT and a full refund, never an empty answer dressed up as "no results".

Result cache. A search identical to one answered in the last60 minutes (same filters, same maxPages, any key order) is served from a short-lived cache instead of a new EUR-Lex round-trip — usually well under 100 ms. The X-Search-Cache header is hit,miss or bypass, and fetchedAt always tells you when the results were really fetched. Partial results are never cached; EUR-Lex's own index updates once a day. Pass fresh: true to force a re-fetch. Cached and live answers cost the same.

Response

responseapplication/json
{
  "searchParameters": { /* echoed back */ },
  "totalResults": 1234,
  "totalPages": 124,
  "pagesFetched": 2,
  "resultCount": 20,
  "results": [
    {
      "celexNumber": "32016R0679",
      "title": "Regulation (EU) 2016/679...",
      "documentType": "Regulation",
      "author": "European Parliament, Council",
      "dateOfDocument": "27/04/2016",
      "url": "https://eur-lex.europa.eu/..."
    }
  ],
  "subscription": { "tier": "PROFESSIONAL", "maxPages": 20 },
  "usage": { "current": 235, "limit": 5000, "remaining": 4765 },
  "fetchedAt": "2025-05-12T10:30:00.000Z"
}

POST /api/v1/documentContent

Get full document content and metadata by CELEX number.

Request body

request
{
  "celexNumber": "32016R0679"
}

Response

responseapplication/json
{
  "success": true,
  "document": {
    "celex": "32016R0679",
    "title": "Regulation (EU) 2016/679...",
    "documentType": "Regulation",
    "author": "European Parliament, Council",
    "dateOfDocument": "27/04/2016",
    "keywords": ["data protection", "privacy"],
    "content": { /* fullText, sections, articles, tables */ },
    "textAvailability": { "available": true },
    "urls": {
      "metadata": "https://eur-lex.europa.eu/.../ALL/?uri=CELEX:32016R0679",
      "html":     "https://eur-lex.europa.eu/.../TXT/HTML/?uri=CELEX:32016R0679",
      "pdf":      "https://eur-lex.europa.eu/.../TXT/PDF/?uri=CELEX:32016R0679",
      "xml":      "https://eur-lex.europa.eu/.../TXT/XML/?uri=CELEX:32016R0679"
    },
    "fetchedAt": "2025-05-12T10:30:00.000Z"
  },
  "subscription": { "tier": "PROFESSIONAL" },
  "usage": { "current": 236, "limit": 5000, "remaining": 4764 }
}

Note: the field name is document.celex, not document.celexNumber — keep that in mind when destructuring.

Text availability

Every document response carries a textAvailability block. Usually it is just { available: true }. EUR-Lex has a class of documents whose notice it publishes but whose text it does not — a corrigendum issued only in its authentic languages, a judgment whose notice lands before its text. For those you get 200 with full metadata, content: null, an X-Text-Available: false header, and a charge of 1 credit rather than 5 — the same price as /documents/metadata, which is all you received. It is deliberately not a 404: the document exists, and /resolve, /citations and /versions all answer for it.

text unavailableapplication/json
{
  "content": null,
  "textAvailability": {
    "available": false,
    "reason": "not_in_language",
    "retryable": true,
    "availableLanguages": ["es", "it", "lv"]
  }
}
reasonWhat it meansWhat to do
not_in_languageThe document has text, but not in the language you asked for.Retry with one of availableLanguages.
not_published_yetThe notice exists ahead of the text.Retry later — we are already re-checking.
no_machine_readable_renditionEUR-Lex publishes no per-document text for it at all.Don't retry.
too_largeThe source page exceeded our parse capacity — a verdict of ours (source: "parse-worker"), not EUR-Lex's; the text exists upstream. availableLanguages is null.Don't retry; re-examined quarterly.
unknownWe could not establish why. An upstream lookup being unavailable is never treated as proof of absence.Retry later.

availableLanguages is null when we don't know and [] when we know there are none — different facts, so don't collapse them. Parliamentary questions are a special case: their text is published inside an Official Journal issue rather than as a per-document rendition, and we extract it for you (X-Source: cellar-oj). Questions from before 2012 have no text anywhere upstream.

Most General Court decisions have no English text and never will — the authentic text is the language of the case, usually alongside French. If your pipeline can consume other languages, pass acceptLanguages (an ordered list of ISO codes, or "any") and the authentic text is served directly in one call, labeled via languageRequested / languageFallback and the X-Language-Fallback header — the same semantics as the automatic English fallback. Works on /documentContent and the batch endpoint.

POST /api/v1/documentContent/batch

Fetch multiple documents in one request. The batch size is capped by tier (1 / 5 / 20 / 50). Excess CELEX numbers are silently dropped and an X-Warning response header is set. Items whose text EUR-Lex does not publish stay in documents[] with their metadata and a textAvailability block — moving them to errors[] would discard what you paid for. The response carries a textUnavailable count and an X-Batch-Text-Unavailable header.

Request body

request
{
  "celexNumbers": [
    "32016R0679",
    "62018CJ0311",
    "52020DC0790"
  ]
}

Response

responseapplication/json
{
  "success": true,
  "requested": 3,
  "processed": 3,
  "successful": 3,
  "failed": 0,
  "documents": [ /* document objects, same shape as /documentContent */ ],
  "errors": [],
  "subscription": { "tier": "PROFESSIONAL", "maxBatchSize": 20 },
  "usage": { /* … */ }
}

GET /api/v1/documents/recent

Get recently published EUR-Lex documents. All parameters are query-string.

Unfiltered requests are answered from Cellar in one query and marked X-Source: cellar; they fetch no result pages and settle at a single page — 3 credits regardless of limit. Requests filtering on documentType, author, domain or subdomain are served from EUR-Lex directly (X-Source: live) and priced per page. The results are the same either way.

Query parameters

FieldTypeDescription
daysintLook-back window in days (default: 7)
documentTypestring / listFilter by type
authorstring / listFilter by author
domainstringEU_LAW, NATIONAL_LAW, ALL
subdomainstring / listFilter by subdomain
languagestringDefault en
limitintMax results (default: 50)
example
$ curl "https://lex-api.com/api/v1/documents/recent?days=14&documentType=judgment&limit=25" \
   -H "x-api-key: $LEX_KEY"

POST /api/v1/documents/url

Extract and fetch a document directly from any EUR-Lex URL. We parse the CELEX out of the URL and resolve to the canonical document.

Request body

request
{
  "url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32016R0679"
}

Response

responseapplication/json
{
  "success": true,
  "sourceUrl": "https://eur-lex.europa.eu/...",
  "extractedCelex": "32016R0679",
  "document": { /* same shape as /documentContent */ },
  "subscription": { /* … */ },
  "usage": { /* … */ }
}

Returns 400 if the URL is not from eur-lex.europa.eu or no CELEX can be extracted.

POST /api/v1/documents/metadata

Get only document metadata — title, date, author, type, keywords — without fetching the full content. Much faster when you only need to identify a document.

Request body

request
{
  "celexNumber": "32016R0679"
}

Response

responseapplication/json
{
  "success": true,
  "metadata": {
    "celexNumber": "32016R0679",
    "title": "Regulation (EU) 2016/679...",
    "documentType": "Regulation",
    "author": "European Parliament, Council",
    "dateOfDocument": "27/04/2016",
    "ecli": null,
    "keywords": ["data protection"],
    "urls": { /* metadata, html, pdf, xml */ }
  },
  "subscription": { /* … */ },
  "usage": { /* … */ }
}

POST /api/v1/resolve

Resolve any legal identifier — a bare CELEX, a pasted EUR-Lex URL (URL-encoded colons supported), an ELI URI, or an ECLI — to the canonical CELEX plus the four document access URLs. Costs 1 credit.

Request body

request
{
  "identifier": "ECLI:EU:C:2020:559"
}

Response

responseapplication/json
{
  "success": true,
  "identifier": "ECLI:EU:C:2020:559",
  "identifierType": "ecli",
  "resolvedVia": "ecli-corpus",
  "celex": "62018CJ0311",
  "urls": { /* metadata, html, pdf, xml */ },
  "subscription": { /* … */ },
  "usage": { /* … */ }
}

identifierType is what the input parsed as (celex · url · eli · ecli). resolvedVia is how the CELEX was found: celex/url/eli resolve purely from the identifier; ecli-corpus and eli-corpus mean a corpus lookup answered (ECLIs always; ELIs when the URI isn’t directly mappable). An identifier that can’t be resolved returns a typed 404 — corpus-lookup forms can only resolve documents already in the corpus.

GET /api/v1/documents/:celex/versions

List a document’s point-in-time version history, newest first. Every content change LexAPI detects (by content hash, between fetches) is kept as an immutable snapshot. These are observation snapshots — the document as LexAPI fetched it — not legal in-force reconstructions; history begins when a document first enters the corpus. Query params: language (2-letter ISO, default en). Cost: 1 credit.

Response

responseapplication/json
{
  "success": true,
  "celex": "32016R0679",
  "language": "en",
  "currentVersion": 12,
  "trackedSince": "2026-05-12T07:57:38.810Z",
  "semantics": "Versions are LexAPI observation snapshots…",
  "versions": [
    {
      "version": 12,
      "isCurrent": true,
      "contentHash": "…",
      "fetchedAt": "2026-06-01T09:00:00.000Z",
      "title": "Regulation (EU) 2016/679…",
      "documentTypeCode": "regulation"
    }, /* … older versions; listings carry no content … */
  ]
}

GET /api/v1/documents/:celex/versions/:version

Fetch one immutable snapshot, including its parsedContent as it stood in that version. The current version is served from the live corpus row; the X-Corpus-Version response header mirrors the returned version. A missing version returns a typed 404 naming the current one. Cost: 1 credit.

request
curl "https://lex-api.com/api/v1/documents/32016R0679/versions/2" \
  -H "x-api-key: $LEX_KEY"

GET /api/v1/documents/:celex/at/:date

The snapshot LexAPI observed as current on a date (YYYY-MM-DD, end-of-day UTC inclusive) — version N counts as current from its own fetchedAt until version N+1’s. Dates before the document entered the corpus return an honest 404 carrying the tracking start date, never a misleading empty result. The response echoes the requested date as asOf. Cost: 1 credit.

request
curl "https://lex-api.com/api/v1/documents/32016R0679/at/2026-06-15" \
  -H "x-api-key: $LEX_KEY"

Together these power reproducible research and compliance audits — “the GDPR as we observed it on the day the report was written” — and versioned RAG pipelines. Both SDKs expose them: get_document_at_date (Python) / getDocumentAtDate (TypeScript).


POST /api/v1/citations/extract

Crawl a document for outbound citations and persist them to the graph. Idempotent — if citations have already been extracted for the CELEX, the response carries alreadyExtracted: true.

Request body

request
{
  "celexNumber": "32016R0679"
}

Response

responseapplication/json
{
  "success": true,
  "message": "Citations extracted successfully",
  "document": {
    "celexNumber": "32016R0679",
    "title": "Regulation (EU) 2016/679..."
  },
  "citationCount": 45,
  "citations": [
    { "targetCelex": "31995L0046", "citationType": "reference" }
  ]
}

GET /api/v1/citations/cited-by/:celexNumber

Documents that cite the target. Grouped by source document.

Query parameters

FieldTypeDescription
limitintPage size (default: 100)
offsetintSkip N results (default: 0)
citationTypestringreference, amendment, repeal, implementation, legal-basis, proposal
responseapplication/json
{
  "success": true,
  "targetDocument": "32016R0679",
  "totalCitations": 456,
  "uniqueDocuments": 123,
  "limit": 100,
  "offset": 0,
  "citedBy": [
    {
      "celexNumber": "52020DC0790",
      "title": "Communication...",
      "citationCount": 3,
      "citations": [
        { "citationType": "reference", "contextSnippet": "...as set out in Regulation 2016/679...", "extractedAt": "2025-04-01T..." }
      ]
    }
  ]
}

totalCitations is the document’s global count; uniqueDocuments counts the citing documents in the returned page (≤ limit), not the global distinct total — page through with offset to enumerate them all. The same applies to /cites below.

GET /api/v1/citations/cites/:celexNumber

Documents the target cites. Same query parameters and shape as /cited-by, but keyed on the source document. Response uses sourceDocument and cites instead of targetDocument and citedBy.

responseapplication/json
{
  "success": true,
  "sourceDocument": "32016R0679",
  "totalCitations": 789,
  "uniqueDocuments": 234,
  "limit": 100,
  "offset": 0,
  "cites": [ /* grouped by targetCelex */ ]
}

GET /api/v1/citations/network/:celexNumber

Both directions of the graph in one call — outbound and inbound, with citation type counts. Query params: limit (max edges per direction; default 100, max 500), offset (paging cursor, both directions), citationType (filter both directions to one type).

responseapplication/json
{
  "success": true,
  "document": "32016R0679",
  "network": {
    "celexNumber": "32016R0679",
    "citesCount": 35,
    "citedByCount": 100,
    "cites": [
      { "celexNumber": "31995L0046", "title": "Directive 95/46/EC...", "count": 2, "types": ["reference"] }
    ],
    "citedBy": [ /* same shape */ ]
  },
  "paging": { "limit": 100, "offset": 0, "citesTotal": 35, "citedByTotal": 456 }
}

citesCount / citedByCount are page-scoped — they count the deduplicated neighbours in the returned page, so a dense document shows citedByCount: 100 at the default limit even when far more documents cite it. The unbounded totals are always in paging.citesTotal / paging.citedByTotal; page with offset to walk the rest. On a server-side timeout the endpoint returns 200 with partial: true, partialReason: "TIMEOUT", an empty network, and null paging totals.

GET /api/v1/citations/path/:from/:to

Shortest citation path between two documents — BFS over the outbound citation graph. Query param maxDepth (default 3, max 8); the visited set is bounded to 5,000 nodes on highly-connected seeds. Costs 20 credits.

responseapplication/json
{
  "success": true,
  "from": "32016R0679",
  "to": "31995L0046",
  "found": true,
  "pathLength": 1,
  "maxDepth": 3,
  "path": [
    { "celexNumber": "32016R0679", "title": "Regulation (EU) 2016/679…", "titleSource": "corpus" },
    { "celexNumber": "31995L0046", "title": "Directive 95/46/EC…", "titleSource": "corpus" }
  ]
}

No path within the budget is not an error: you get a 200 with found: false, pathLength: null and a message.

Bibliographic-coupling neighbours — documents that share the most outbound citation targets with the seed. Pure graph logic; no live EUR-Lex calls. Query param limit (default 10, max 50). Costs 20 credits.

responseapplication/json
{
  "success": true,
  "celexNumber": "32016R0679",
  "method": "bibliographic-coupling",
  "seedCitationCount": 35,
  "count": 2,
  "related": [
    { "celexNumber": "32016L0680", "title": "Directive (EU) 2016/680…", "sharedCount": 9, "sharedTargets": ["31995L0046", "…"], "titleSource": "corpus" }
  ]
}

A seed with no outbound citations returns a graceful empty list with a message — extract its citations first if you expect coupling.

GET /api/v1/citations/stats

Global statistics on the citation graph — top cited documents, top citers, totals.

responseapplication/json
{
  "success": true,
  "stats": {
    "totalCitations": 456789,
    "uniqueSourceDocuments": 12345,
    "uniqueTargetDocuments": 23456,
    "mostCited": [
      { "celexNumber": "32016R0679", "title": "GDPR...", "citedByCount": 5678 }
    ],
    "mostCiting": [
      { "celexNumber": "52020DC0790", "title": "Communication...", "citesCount": 123 }
    ]
  }
}

AI-powered semantic search over EU case law using embeddings. Find relevant rulings by meaning, not just keywords. Case-law results are deduplicated by CELEX; each result carries a compact snippet by default — pass include: ["text"] for the full text of every matched document. Costs 5 credits — or 15 with hyde: true.

Request body

request
{
  "query": "data protection breach notification obligations",
  "min_score": 0.7,
  "limit": 20,
  "filters": { "year_from": 2020 },
  "hyde": false
}

Parameters

FieldTypeDescription
querystring (required)Natural-language query
min_scorenumber 0–1Minimum relevance score (optional)
limitintMax results; capped by tier (20 / 50 / 100)
filtersobjectAdditional filter object passed through to the semantic service
includearray["text"] returns the full matched-document text on each result (tens–hundreds of KB per hit). Default: a ~600-character snippet instead — a 10–50× payload reduction.
languagestringContent language (default en). The semantic index currently covers en, fr, de, es, it, sl, el, nl, pt, pl; an unsupported code returns 400.
hydebooleanHyDE query rewriting (default false): an LLM drafts the passage a relevant judgment would contain and retrieval fuses both rankings — markedly better recall on short, keyword-style queries. 15 credits instead of 5, ~1–3 s extra latency. If generation fails the search falls back to plain retrieval, the response reports "hyde": false, and the 10-credit premium is refunded automatically. When it runs, the response includes the generated hypotheticalDocument for relevance debugging.

Response — case-law result shape

Each entry is a structured case record. Note: score is at the top level of each result, not nested under metadata. The metadata sub-object carries the CELEX and document type (and, when available, the paragraph number). Fields the upstream extractor could not populate are omitted entirely rather than sent as null — never assume a key’s presence.

responseapplication/json
{
  "success": true,
  "searchType": "semantic-case-law",
  "query": "data protection breach notification",
  "resultCount": 2,
  "results": [
    {
      "case_id": "ECLI_EU_C_2024_785_en_c0007",
      "score": 0.764,
      "jurisdiction": "EU",
      "language": "en",
      "case_name": "Land Hessen (Obligation d'agir de l'autorité de protection des données)",
      "court": "Court of Justice",
      "decision_date": "2024-09-26",
      "case_number": "C-768/21",
      "ecli": "ECLI:EU:C:2024:785",
      "parties": "Land Hessen v TR",
      "summary": "The Court examined whether, under the GDPR…",
      "snippet": "The Court examined whether, under the GDPR, the supervisory authority…",
      "metadata": {
        "celex_id": "62021CJ0768",
        "document_type": "judgment"
      }
    }
  ],
  "metadata": { /* processing_time_ms, model, … */ },
  "tookMs": 3120,
  "subscription": { "tier": "PROFESSIONAL", "maxResults": 50 }
}

semanticUsage appears on legacy daily-call plans only. On credit plans it is omitted — semantic search is metered from the monthly credit pool and reported in the credits envelope instead.

limit is optional and must be an integer of 1 or greater; it is clamped down to your plan's maxResults. A limit below 1 returns 400 Invalid limit.

Without an explicit min_score, the upstream index applies a default relevance floor of ~0.7 — a quality filter that can leave you with fewer results than limit. If that happens, pass min_score: 0.5 (or 0 to disable the floor) to widen recall.

Result fields

FieldNotes
case_idSnapshot-scoped id — stable within one index snapshot, regenerated when the upstream index is rebuilt. For a persistent key use metadata.celex_id + metadata.document_type.
scoreFloat 0–1. Higher = more relevant.
jurisdiction · languageUsually EU / en.
case_name · case_number · ecli · partiesHuman-readable identifiers.
court · decision_dateIssuing court and ISO-8601 date.
summaryLLM-generated digest of the ruling. Useful for previews.
snippet≤600-character preview (head of the summary, else of the matched text). Returned by default, in place of text.
textThe full matched-document text — only present with include: ["text"] (tens–hundreds of KB per hit).
metadata.celex_idCELEX you can feed into /documentContent.
metadata.document_typejudgment, order, ag_opinion, … Together with celex_id, the identity key that survives index rebuilds.
metadata.paragraph_numberPosition of the match within the judgment — when available.
outcome · claimed_amount · legal_area · was_appealed · extraction_* · …Structured analytics, present only where the upstream extractor was confident — omitted otherwise (never null).

A best-effort response (top hit below your min_score, or the adaptive floor engaged) carries a hint string explaining that the results are low-confidence; the key is absent otherwise. tookMs is the server-measured wall-clock time for the request.

Legacy (pre-credits) FREE keys get 403. Legacy monthly-quota exhaustion returns 429 with a usage.resetsAt timestamp. Upstream service errors return 502; timeouts return 504. Results are deduplicated by CELEX server-side, so a tight limit may produce fewer hits than requested even on dense topics.

POST /api/v1/legislation/semantic

Embedding-based semantic search over EU legislation, matched at article level. Same request shape as /search/semantic (query, limit, min_score, filters, language, include) — but no HyDE: hyde is ignored on this endpoint. Costs 5 credits.

responseapplication/json
{
  "success": true,
  "searchType": "semantic-legislation",
  "query": "right to be forgotten",
  "resultCount": 2,
  "results": [
    {
      "id": "86ef6918-c491-5568-b387-e4bdd7c17f81",
      "law_id": "32018R1725",
      "law_title": "Regulation (EU) 2018/1725 of the European Parliament and of the Council…",
      "article_ref": "art_19",
      "score": 0.728,
      "exact_match_law_id": false,
      "snippet": "1. The data subject shall have the right to obtain from the controller the erasure of…"
    }
  ],
  "metadata": { /* upstream pass-through — shape not guaranteed */ },
  "tookMs": 973
}

law_id is a CELEX — feed it into /documentContent, and use article_ref (art_19) as its articleId to fetch just the matched article. The same ~0.7 default relevance floor and best-effort hint semantics as /search/semantic apply.


GET /api/v1/export BUSINESS only

Stream the LexAPI corpus as NDJSON (newline-delimited JSON) — one document per line. Suitable for ETL into your own datastore, search indexes, retrieval (RAG) pipelines, or compliance archives. The endpoint cursors through Postgres in batches and writes each row as it goes, so memory usage on both ends stays bounded regardless of export size.

Licensing. Bulk export is licensed for building your own products and internal systems on the API. Using exported content — including our parsed structure — to train, fine-tune or evaluate machine-learning models, or to create embeddings or derived datasets for those purposes, needs a separate written licence from us (Terms §6) — email [email protected]. Retrieval and search embeddings built to serve your own application are not covered by that restriction.

Query parameters

FieldTypeDescription
documentTypestring / listFilter by document type
authorstring / listFilter by author
languagestringDefault en
dateFrom · dateToYYYY-MM-DDDocument-date range
fetchedSinceISO timestampIncremental sync — only rows we re-fetched after this time
includeContentboolInclude parsed sections/articles/tables (default true)
includeHtmlboolInclude raw HTML blobs (large; default false)
includeStubsboolAlso stream metadata-only rows (documents whose text EUR-Lex does not publish, or not yet). Default false; skipped rows are counted as skippedStubs on the _done trailer.
limitintCap on rows returned (server max: 50,000)

Response format

Content-Type: application/x-ndjson. The response stream is framed by an envelope: the first line is a { "_meta": … } object describing the filters and counts, the middle is one document per line, and the last line is { "_done": { written, truncated } }.

response (NDJSON stream)
{"_meta": {"totalMatching":12450, "streaming":12450, "truncated":false, /* … */}}
{"celex":"32016R0679","title":"Regulation (EU) 2016/679…","documentType":"Regulation","author":"…","parsedContent":{…},"version":1,"contentHash":"…","fetchedAt":"2026-05-12T…"}
{"celex":"32022R2065",/* … */}
/* … one line per document … */
{"_done": {"written":12450, "truncated":false}}

Response headers

HeaderNotes
X-Export-TotalTotal matching rows (regardless of limit)
X-Export-StreamingRows actually being streamed
X-Export-Truncated1 if the cap was hit; 0 otherwise

Examples

bash
# Incremental sync — pull everything we've refreshed since yesterday
$ curl -L "https://lex-api.com/api/v1/export?fetchedSince=2026-05-11T00:00:00Z" \
   -H "x-api-key: $LEX_KEY" \
   -o yesterday.ndjson

# All EU regulations from 2020 onward, full content but no raw HTML
$ curl -L "https://lex-api.com/api/v1/export?documentType=regulation&dateFrom=2020-01-01" \
   -H "x-api-key: $LEX_KEY" \
   -o eu-regulations.ndjson

# Parse as a stream in Node
$ node -e "const rl = require('readline').createInterface({input: process.stdin}); rl.on('line', l => { const r = JSON.parse(l); if (r._meta || r._done) return; console.log(r.celex, r.title.slice(0,60)); });" < eu-regulations.ndjson

403 for any non-BUSINESS tier. X-Export-Truncated: 1 indicates the result set exceeded the limit — for full backfills, page using fetchedSince or repeated calls with widening dateFrom/dateTo windows. On credit plans an export is metered per row returned; on legacy daily plans one export call counts as one call against your daily quota.


POST /api/v1/webhooks

Create a webhook subscription. The secret in the response is returned only once — store it; you will need it to verify incoming payload signatures.

Request body

request
{
  "name": "New GDPR-related judgments",
  "url": "https://myapp.example.com/lexapi-webhook",
  "searchCriteria": {
    "query": "GDPR",
    "documentType": "judgment"
  },
  "secret": "optional-supplied-secret"
}

Response (201 Created)

responseapplication/json
{
  "success": true,
  "message": "Webhook created successfully",
  "webhook": {
    "id": "a1b2c3...",
    "name": "New GDPR-related judgments",
    "url": "https://myapp.example.com/lexapi-webhook",
    "secret": "7f9a...",            // returned ONCE
    "status": "ACTIVE",
    "searchCriteria": { /* … */ },
    "createdAt": "2025-05-12T10:30:00.000Z"
  }
}

Delivery format

When a webhook fires, we POST to your URL with these headers and a JSON body:

delivery
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: LexAPI-Webhook/1.0
X-Webhook-ID: a1b2c3...
X-Webhook-Signature: <hmac-sha256(secret, body) hex>

{
  "event": "webhook.test",
  "timestamp": "2025-05-12T10:30:00.000Z",
  "webhook": { "id": "a1b2...", "name": "..." },
  "data": { /* matched documents */ }
}

GET /api/v1/webhooks

List every webhook on the account.

responseapplication/json
{
  "success": true,
  "count": 3,
  "webhooks": [
    {
      "id": "a1b2c3...",
      "name": "New GDPR judgments",
      "url": "https://...",
      "status": "ACTIVE",
      "searchCriteria": { /* … */ },
      "lastChecked": "2025-05-12T10:00:00.000Z",
      "lastTriggered": "2025-05-12T09:30:00.000Z",
      "consecutiveFailures": 0,
      "deliveryCount": 12,
      "createdAt": "2025-04-01T..."
    }
  ]
}

GET /api/v1/webhooks/:id

Single-webhook detail, including the last 20 delivery records.

responseapplication/json
{
  "success": true,
  "webhook": {
    "id": "a1b2...",
    "name": "...",
    "url": "...",
    "status": "ACTIVE",
    "searchCriteria": { /* … */ },
    "lastChecked": "...",
    "lastTriggered": "...",
    "consecutiveFailures": 0,
    "maxRetries": 3,
    "createdAt": "...",
    "updatedAt": "...",
    "recentDeliveries": [ /* last 20 */ ]
  }
}

PUT /api/v1/webhooks/:id

Update any of name, url, searchCriteria, or status. Setting status to ACTIVE also clears the failure counter. Allowed status values: ACTIVE, PAUSED, FAILED.

request
{
  "name": "GDPR judgments (paused)",
  "status": "PAUSED"
}

DELETE /api/v1/webhooks/:id

Permanently delete a webhook and its delivery history.

responseapplication/json
{
  "success": true,
  "message": "Webhook deleted successfully"
}

POST /api/v1/webhooks/:id/test

Send a one-off webhook.test event to the webhook's URL and return the upstream response. Useful for verifying signature handling before going live.

responseapplication/json
{
  "success": true,
  "message": "Test webhook delivered successfully",
  "delivery": {
    "status": "SUCCESS",
    "responseStatus": 200,
    "responseBody": "{...}",
    "errorMessage": null
  }
}

GET /api/v1/webhooks/:id/deliveries

Paginated delivery history.

Query parameters

FieldTypeDescription
limitintPage size (default: 50)
offsetintSkip N (default: 0)
responseapplication/json
{
  "success": true,
  "total": 156,
  "limit": 50,
  "offset": 0,
  "deliveries": [
    {
      "id": "d1e2...",
      "webhookId": "a1b2...",
      "status": "SUCCESS",
      "responseStatus": 200,
      "responseBody": "...",
      "errorMessage": null,
      "createdAt": "2025-05-12T09:30:00.000Z"
    }
  ]
}

Node.js example

search.js
const res = await fetch('https://lex-api.com/api/v1/search', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.LEX_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    query: 'GDPR data protection',
    documentType: ['judgment', 'opinion'],
    year: [2023, 2024],
    maxPages: 2,
  }),
});
const data = await res.json();

Python example

search.py
import requests

res = requests.post(
  'https://lex-api.com/api/v1/search',
  json={
    'query': 'GDPR data protection',
    'documentType': ['judgment', 'opinion'],
    'maxPages': 2,
  },
  headers={'x-api-key': API_KEY},
)
data = res.json()

cURL example

bash
$ curl -X POST "https://lex-api.com/api/v1/search" \
   -H "x-api-key: $LEX_KEY" \
   -H "Content-Type: application/json" \
   -d '{"query":"GDPR","year":[2023,2024]}'

Verifying webhook signatures (Node)

verify.js
const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader),
  );
}

End of documentationNeed help? [email protected]