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.
# 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.
| Header | Required | Notes |
|---|---|---|
x-api-key | yes | Your API key from the dashboard |
Content-Type | yes (POST/PUT) | application/json |
Accept | no | Defaults 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.
| Tier | Price | Credits / month | Rate / min | Max pages | Batch size | Webhooks |
|---|---|---|---|---|---|---|
| FREE | €0 | 500 | 15 | 1 | 1 | 0 |
| STARTER | €15/mo | 30,000 | 60 | 5 | 5 | 2 |
| PROFESSIONAL | €39/mo | 150,000 | 120 | 20 | 20 | 10 |
| BUSINESS | €99/mo | 500,000 | 300 | 100 | 50 | 50 |
| ENTERPRISE | custom | custom | custom | 100 | 50 | 50 |
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
| Credits | Operations |
|---|---|
| 0 | /info — always free |
| 1 | Resolve, 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 / page | Keyword 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 |
| 4 | The 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 |
| 5 | Document 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) |
| 5 | Semantic search (case law & legislation) |
| 15 | Semantic case-law search with hyde: true (LLM query rewriting; auto-refunded to 5 if rewriting falls back) |
| 20 | Citation 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.
{
"error": "Rate limit exceeded",
"message": "You're sending requests faster than your plan allows",
"retryAfter": 45
}{
"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"
}
}
}| Status | Meaning |
|---|---|
| 400 | Bad request — invalid parameters or missing required fields |
| 401 | Missing or invalid API key (or key has been deactivated) |
| 402 | Monthly credit pool exhausted — buy a top-up or wait for the reset. Carries a credits/details breakdown with resetsAt |
| 403 | No active subscription, or FREE tier on a paid-only endpoint |
| 404 | Document or resource not found |
| 429 | Per-minute rate limit exceeded (or, on legacy daily plans, the daily quota) — retry with backoff |
| 5xx | Server 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
pip install lexapi-client
The distribution is lexapi-client; the import is lexapi:
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
npm install @lexapi/client
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:
claude mcp add lexapi --env LEXAPI_API_KEY=lex_your_key -- npx -y @lexapi/mcp
Claude Desktop (claude_desktop_config.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
| Dimension | Coverage |
|---|---|
| Domain | EU_LAW (all subdomains: legislation, case law, treaties, opinions, communications, …) |
| Date range | 2010 → present, with ongoing hourly delta |
| Language | English 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 latency | Hourly 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
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:
$ 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.
{
"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", /* … */]
}POST /api/v1/search
Search EUR-Lex with user-friendly parameters (no URL building required).
Request body
{
"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
| Field | Type | Description |
|---|---|---|
query | string | Search 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 |
textScope | string | title, text, title-text (default), or any |
dateFrom · dateTo | YYYY-MM-DD | Date range filter |
year · month | int / list | Specific year(s) or month(s) |
documentType | string / list | judgment, directive, regulation, decision, communication, … |
author | string / list | commission, council, parliament, court-of-justice, general-court, … |
procedure | string / list | ordinary, codecision, consent, consultation, … |
domain | string | EU_LAW, NATIONAL_LAW, ALL |
subdomain | string / list | LEGISLATION, CONSLEG, TREATIES, EU_CASE_LAW, … |
language | string | Two-letter language code (default: en) |
maxPages | int | Result pages to fetch (default: 1, capped by tier) |
fresh | bool | true 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
{
"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
{
"celexNumber": "32016R0679"
}Response
{
"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.
{
"content": null,
"textAvailability": {
"available": false,
"reason": "not_in_language",
"retryable": true,
"availableLanguages": ["es", "it", "lv"]
}
}| reason | What it means | What to do |
|---|---|---|
not_in_language | The document has text, but not in the language you asked for. | Retry with one of availableLanguages. |
not_published_yet | The notice exists ahead of the text. | Retry later — we are already re-checking. |
no_machine_readable_rendition | EUR-Lex publishes no per-document text for it at all. | Don't retry. |
too_large | The 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. |
unknown | We 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
{
"celexNumbers": [
"32016R0679",
"62018CJ0311",
"52020DC0790"
]
}Response
{
"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
| Field | Type | Description |
|---|---|---|
days | int | Look-back window in days (default: 7) |
documentType | string / list | Filter by type |
author | string / list | Filter by author |
domain | string | EU_LAW, NATIONAL_LAW, ALL |
subdomain | string / list | Filter by subdomain |
language | string | Default en |
limit | int | Max results (default: 50) |
$ 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
{
"url": "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32016R0679"
}Response
{
"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
{
"celexNumber": "32016R0679"
}Response
{
"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
{
"identifier": "ECLI:EU:C:2020:559"
}Response
{
"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
{
"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.
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.
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
{
"celexNumber": "32016R0679"
}Response
{
"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
| Field | Type | Description |
|---|---|---|
limit | int | Page size (default: 100) |
offset | int | Skip N results (default: 0) |
citationType | string | reference, amendment, repeal, implementation, legal-basis, proposal |
{
"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.
{
"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).
{
"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.
{
"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.
GET /api/v1/citations/related/:celexNumber
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.
{
"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.
{
"success": true,
"stats": {
"totalCitations": 456789,
"uniqueSourceDocuments": 12345,
"uniqueTargetDocuments": 23456,
"mostCited": [
{ "celexNumber": "32016R0679", "title": "GDPR...", "citedByCount": 5678 }
],
"mostCiting": [
{ "celexNumber": "52020DC0790", "title": "Communication...", "citesCount": 123 }
]
}
}POST /api/v1/search/semantic All credit plans
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
{
"query": "data protection breach notification obligations",
"min_score": 0.7,
"limit": 20,
"filters": { "year_from": 2020 },
"hyde": false
}Parameters
| Field | Type | Description |
|---|---|---|
query | string (required) | Natural-language query |
min_score | number 0–1 | Minimum relevance score (optional) |
limit | int | Max results; capped by tier (20 / 50 / 100) |
filters | object | Additional filter object passed through to the semantic service |
include | array | ["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. |
language | string | Content language (default en). The semantic index currently covers en, fr, de, es, it, sl, el, nl, pt, pl; an unsupported code returns 400. |
hyde | boolean | HyDE 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.
{
"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
| Field | Notes |
|---|---|
case_id | Snapshot-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. |
score | Float 0–1. Higher = more relevant. |
jurisdiction · language | Usually EU / en. |
case_name · case_number · ecli · parties | Human-readable identifiers. |
court · decision_date | Issuing court and ISO-8601 date. |
summary | LLM-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. |
text | The full matched-document text — only present with include: ["text"] (tens–hundreds of KB per hit). |
metadata.celex_id | CELEX you can feed into /documentContent. |
metadata.document_type | judgment, order, ag_opinion, … Together with celex_id, the identity key that survives index rebuilds. |
metadata.paragraph_number | Position 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.
{
"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
| Field | Type | Description |
|---|---|---|
documentType | string / list | Filter by document type |
author | string / list | Filter by author |
language | string | Default en |
dateFrom · dateTo | YYYY-MM-DD | Document-date range |
fetchedSince | ISO timestamp | Incremental sync — only rows we re-fetched after this time |
includeContent | bool | Include parsed sections/articles/tables (default true) |
includeHtml | bool | Include raw HTML blobs (large; default false) |
includeStubs | bool | Also 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. |
limit | int | Cap 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 } }.
{"_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
| Header | Notes |
|---|---|
X-Export-Total | Total matching rows (regardless of limit) |
X-Export-Streaming | Rows actually being streamed |
X-Export-Truncated | 1 if the cap was hit; 0 otherwise |
Examples
# 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
{
"name": "New GDPR-related judgments",
"url": "https://myapp.example.com/lexapi-webhook",
"searchCriteria": {
"query": "GDPR",
"documentType": "judgment"
},
"secret": "optional-supplied-secret"
}Response (201 Created)
{
"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:
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.
{
"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.
{
"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.
{
"name": "GDPR judgments (paused)",
"status": "PAUSED"
}DELETE /api/v1/webhooks/:id
Permanently delete a webhook and its delivery history.
{
"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.
{
"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
| Field | Type | Description |
|---|---|---|
limit | int | Page size (default: 50) |
offset | int | Skip N (default: 0) |
{
"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
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
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
$ 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)
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), ); }