# Leya — full agent reference Leya is a REST search API over primary Latin American legal sources. This document is the complete integration reference: auth, every endpoint, every response field, error handling, and worked examples. A shorter orientation lives at https://leya.lawyer/llms.txt. Base URL: `https://api.leya.lawyer` OpenAPI: `https://api.leya.lawyer/openapi.public.json` ## What problem this solves An LLM asked about Guatemalan or Dominican law will produce fluent, plausible, and frequently wrong statute numbers. Leya replaces recall with retrieval: you search a corpus of real documents, fetch the text you're going to quote, and cite the id you retrieved. Anything you can't retrieve, you don't know. ## Authentication Authorization: Bearer leya_live_<32 hex chars> Get a key at https://leya.lawyer/api-keys. Two prefixes exist: - `leya_live_` — production. - `leya_test_` — development. Same corpus, same limits; useful for keeping test traffic separable in your own logs. Keys are hashed at rest and shown once at creation. A revoked key returns 401 with code `unauthenticated`. A key is not the only way in: every `/v1` read also accepts per-request payment with no account — see **Paying per call** below. ## Rate limits Per key, per replica, sliding window: - `/v1/search`: 60 requests/minute. Tighter than the rest because each search computes a query embedding. - All other `/v1` reads: 300 requests/minute. Every successful response carries `X-RateLimit-Limit` and `X-RateLimit-Window`. A 429 carries `Retry-After` (seconds), `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` (unix seconds). Correct backoff: read `Retry-After`, wait that long, retry once. If the retry also 429s, stop and report it. Do not loop. ## Paying per call (no account) Every `/v1` read accepts payment in place of an API key, under MPP. Call without credentials and the response is `402` with a `WWW-Authenticate: Payment` challenge; settle it and retry the identical request. There is no signup, no key and no balance. tempo request "https://api.leya.lawyer/v1/search?q=despido+injustificado&country=GT" Prices are the credit costs above converted at $0.01/credit — a search is $0.03, a document read $0.01. Free operations (`/v1/sources`, `/v1/filters`, `/v1/cadastre/layers`) are served without any challenge, so you can check coverage before paying for anything. The 402 body carries the machine-readable price: {"error": {"code": "payment_required", "status": 402, "retryable": false, "message": "This request costs 0.030000. Settle the challenge and retry...", "payment": {"amount": "0.030000", "credits": 3, "method": "tempo", "docs": "https://mpp.dev"}}} Discovery: `GET https://api.leya.lawyer/openapi.json` is an OpenAPI 3.1 document annotated per `draft-payment-discovery-00` — `x-service-info` at the top level, and `x-payment-info` offers on each payable operation with the amount in the settlement token's base units. A credential is spent exactly once. Replaying one does not buy a second call. ### Key or payment? - **Pay per call** for one-off, exploratory or agent-initiated work. Nothing to set up, nothing to manage, and you pay only for what you use. - **Get a key** for volume — prepaid credits cost the same but settle once instead of per request — or when you want usage history and a visible balance. `/v1/me` and `/v1/credits` describe an account and stay key-only. ## Credits Calls are prepaid. A new account receives **1,000 credits free** on its first request (~300 searches), so evaluation costs nothing. | Endpoint | Credits | |---|---| | `GET /v1/search` | 3 | | `GET /v1/parcels/{ccc}`, `GET /v1/parcels` | 2 | | `GET /v1/documents/{id}` | 1 | | `GET /v1/documents/{id}/citation` | 1 | | `GET /v1/cadastre/coverage` | 1 | | `GET /v1/me`, `/v1/credits`, `/v1/sources`, `/v1/filters` | 0 | Search is dearest because it is the only endpoint that spends money per call — it buys a query embedding. Introspection and top-up are free deliberately: an account at zero must still be able to see why, and buy more. Credits are charged **before** the handler runs, and refunded automatically if the request fails with a 5xx. Charged responses carry `X-Credits-Charged`. `GET /v1/credits` is the authoritative, live answer — balance, price per credit, the full cost table, recent ledger entries. Read it rather than hard-coding the numbers above. {"balance": 997, "enabled": true, "credit_price_usd": 0.01, "costs": {"/v1/search": 3}, "recent": [{"delta": -3, "balance_after": 997, "reason": "charge:/v1/search"}]} ### Out of credits {"error": {"code": "insufficient_credits", "status": 402, "retryable": false, "message": "Insufficient credits: needs 3 credits, balance is 1.", "credits": {"required": 3, "balance": 1, "shortfall": 2, "top_up_usd": 0.01, "top_up_url": "https://leya.lawyer/api-keys"}}} 402 is not 429. A rate limit clears by waiting; an empty balance never does. `retryable` is false, and an agent that backs off and retries will loop forever. Top up, or tell the user you are out of credits and stop. ### Buying credits (Machine Payments Protocol) `POST /v1/credits/purchase?credits=N` speaks MPP (https://mpp.dev), the HTTP payment scheme in IETF draft `draft-ryan-httpauth-payment`, co-authored by Stripe and Tempo. Credits cost $0.01 each ($10 per 1,000); `N` is between 100 and 1,000,000. Settlement is in stablecoins on Tempo. One request cycle — no webhooks, no redirects: 1. `POST /v1/credits/purchase?credits=1000` with no payment, giving `402` + `WWW-Authenticate: Payment id="...", realm="leya", ...` 2. Settle the challenge with your wallet. 3. Retry the same request with `Authorization: Payment `, giving `200 {"credited": 1000, "balance": 1997, "payment_reference": "0x...",` `"payer": "did:pkh:..."}` Any MPP client does all three steps for you: tempo request -X POST \ "https://api.leya.lawyer/v1/credits/purchase?credits=1000" \ -H "X-Leya-Api-Key: $LEYA_API_KEY" **This route takes the API key in `X-Leya-Api-Key`, not `Authorization`.** The payment scheme owns `Authorization` here, and two auth schemes cannot share one header. The key names the account the credits land on. Purchases are idempotent per settlement reference: replaying a credential returns the same balance rather than buying twice. A receipt arriving with no reference credits nothing and returns 502 — better to refuse a purchase than to risk crediting it twice. ## GET /v1/search | Param | Type | Notes | |---|---|---| | `q` | string, required | Natural-language or keyword. Spanish generally outperforms English against a Spanish corpus. | | `country` | ISO-2 | `GT`, `DO`. Omit to search all jurisdictions. | | `source_id` | string | From `/v1/sources`. | | `document_type` | string | Corpus-defined, e.g. `decreto`, `iniciativa`, `sentencia`. | | `legal_area` | string | Corpus-defined, e.g. `laboral`, `mercantil`, `fiscal`. | | `as_of` | YYYY-MM-DD | Excludes documents known to be outside force then. Rows with an unknown `effective_from` are kept — see below. | | `limit` | int 1–50 | Default 10. 51+ is a 422, not a clamp. | Example: curl -sG https://api.leya.lawyer/v1/search \ -H "Authorization: Bearer $LEYA_API_KEY" \ --data-urlencode "q=plazo de prescripción laboral" \ --data-urlencode "country=GT" \ --data-urlencode "limit=3" Response: { "query": "plazo de prescripción laboral", "result_count": 3, "limit": 3, "truncated": true, "results": [ { "document_id": "decreto-1441", "source_id": "gt-congreso", "title": "Código de Trabajo", "snippet": "Artículo 258. Los derechos ...", "score": 0.83, "url": "https://www.congreso.gob.gt/...", "effective_from": "1961-05-01", "effective_to": null, "publication_date": "16 de junio de 1961", "source_last_indexed": "2026-08-04T02:11:07Z" } ] } Field semantics that matter: - `document_id` — stable; pass verbatim to the document and citation endpoints. - `snippet` — ~500 chars, drawn from the summary or the head of the body. **Not sufficient to quote from.** Fetch the document for verbatim text. - `score` — hybrid keyword + vector relevance. Comparable within one response, not across queries. - `effective_from` / `effective_to` — null `effective_from` means unknown, not undated and not current. Most documents have it. - `effective_from_basis` — `stated_date` when the document names the date; `on_publication` or `days_after_publication` when it was read off the document's own vigencia clause plus its publication date. Leya never applies a jurisdiction's default vigencia rule, so a document that states nothing stays null instead of getting a plausible guess. - `publication_date` — free-form string, because sources write it inconsistently ("16 de junio de 1961", "1961-06-16"). Prefer `effective_from` for comparisons. - `source_last_indexed` — when that dataset was last ingested. This is your freshness signal; cite it when the user asks about pending or recent law. - `has_more` — another page exists. Determined by a lookahead row, so it is exact, not a guess from a full page. - `next_cursor` — pass back as `cursor` to continue. Opaque; don't parse it. - `truncated` — deprecated alias for `has_more`. ### What as_of does and doesn't promise The filter drops rows whose `effective_from` is after the date or whose `effective_to` is before it. A null bound is *unknown*, not *matching*, so those rows are kept rather than dropped — requiring a known `effective_from` would return almost nothing, since most of the corpus lacks one. So `as_of` narrows to "not known to be outside force", not "proven in force". Read each result's `effective_from`: null means you cannot assert the document was in force, and saying so is the correct answer. ### Pagination # page 1 GET /v1/search?q=prescripcion&country=GT&limit=25 -> {"has_more": true, "next_cursor": "eyJ2IjoxLCJ..."} # page 2 — same q, same filters, plus the cursor GET /v1/search?q=prescripcion&country=GT&limit=25&cursor=eyJ2IjoxLCJ... The cursor is a keyset position in one query's score ordering. Scores aren't comparable across queries, so a cursor carries a fingerprint of the q and filters that produced it: replaying it against anything else is a 422 rather than a plausible-looking wrong page. Change a filter and you start over from page 1. Only claim an exhaustive search after `has_more` comes back false. ### Empty results `country`, `document_type` and `legal_area` are validated against the corpus: an unknown value is a 422 whose `fields[]` lists what is accepted. So `result_count: 0` now means genuine no-coverage, not a typo. `GET /v1/filters` enumerates the values up front. `source_id` is not validated this way — take those from `/v1/sources`. ## GET /v1/filters { "countries": [{"value": "GT", "document_count": 960}], "document_types": [{"value": "decreto", "document_count": 412}], "legal_areas": [{"value": "laboral", "document_count": 87}] } Pass `value` verbatim to the matching search filter. ## GET /v1/documents/{document_id} curl -s https://api.leya.lawyer/v1/documents/decreto-1441 \ -H "Authorization: Bearer $LEYA_API_KEY" Response: { "document_id": "decreto-1441", "source_id": "gt-congreso", "title": "Código de Trabajo", "body_markdown": "# Código de Trabajo\n\nArtículo 1. ...", "has_body": true, "body_length": 412883, "summary": "...", "url": "https://www.congreso.gob.gt/...", "document_type": "decreto", "issuing_body": "Congreso de la República", "publication_date": "16 de junio de 1961", "effective_from": "1961-05-01", "effective_to": null } `has_body: false` with `body_length: 0` means indexed-but-not-extracted. Report that as a gap. Do not treat it as an empty document and do not supply the text yourself. `body_markdown` is unbounded — `body_length` above is a real 400KB statute. Check `body_length` before loading a document into a constrained context, and prefer searching for the relevant article over reading whole codes. ## GET /v1/documents/{document_id}/citation { "document_id": "decreto-1441", "source_id": "gt-congreso", "title": "Código de Trabajo", "formatted": "Decreto 1441, Congreso de la República (1961)", "style": "latam-standard", "document_type": "decreto", "number": "1441", "issuing_body": "Congreso de la República", "year": 1961 } `style` is the contract: - `latam-standard` — the corpus had type, number and issuing body, so `formatted` is the full ` , <Órgano Emisor> ()` form. - `title-fallback` — that metadata is missing. `formatted` is `" (<source_id>, <document_id>)"` and the citation is partial. In both cases use `formatted` verbatim and, for `title-fallback`, say the citation is incomplete. The component fields show exactly what the corpus knows; a null there means unknown, and inferring it is fabrication. ## GET /v1/sources [{"id": "gt-congreso", "country": "GT", "name": "Congreso de la República", "type": "legislation", "languages": ["es"]}] ## GET /v1/me {"user_id": "...", "email": "...", "name": "...", "key_prefix": "leya_live_ab", "key_type": "live", "requests_last_30d": 1284, "rate_limits": {"search_per_minute": 60, "reads_per_minute": 300}} Read this to learn your own budget rather than hard-coding the numbers above. ## GET /api/corpus/status (no auth) Per-source and per-country document counts, date spans, last ingest time and status. This is the authoritative answer to "what does Leya cover?" — prefer it over any static list, including the ones in this document. ## Errors One envelope for every `/v1` failure: { "error": { "code": "rate_limited", "message": "rate limit of 60 requests per 60s exceeded", "status": 429, "retryable": true, "docs": "https://leya.lawyer/docs/api#errors" } } Codes: `bad_request` (400), `unauthenticated` (401), `insufficient_credits` (402), `payment_required` (402, purchase route only), `forbidden` (403), `not_found` (404), `parcel_not_found` (404), `method_not_allowed` (405), `invalid_request` (422, plus `fields[]`), `rate_limited` (429), `internal_error` (500), `unavailable` (503). Branch on `code`. Use `retryable` to decide between backing off and giving up. Never parse `message`. A 422 adds field detail: {"error": {"code": "invalid_request", "status": 422, "retryable": false, "fields": [{"field": "limit", "problem": "Input should be less than or equal to 50"}]}} ## A complete research loop 1. `GET /v1/search?q=<question in Spanish>&country=<ISO2>` — 5–10 results. 2. Pick 1–3 by title and snippet. Don't fetch everything. 3. `GET /v1/documents/{id}` for each, and quote only from that text. 4. `GET /v1/documents/{id}/citation` for each document you cite. 5. Answer with every claim attached to a retrieved document. Where the corpus was silent, say so. Where `effective_from` was null, say the date is unknown. Where `truncated` was true, say the search may not be exhaustive. ## Failure modes to avoid - Citing a `document_id` you did not retrieve in this session. - Completing a `title-fallback` citation with a remembered decree number. - Reading a null `effective_from` as "currently in force". - Treating `has_body: false` as an empty document. - Presenting a `truncated: true` search as complete coverage. - Substituting another country's law when the requested jurisdiction is absent. - Retrying a 429 in a loop instead of honouring `Retry-After` once. - Retrying a 402 `insufficient_credits` on a backoff. It never clears by waiting; buy credits, or stop. - Treating the 402 from `/v1/credits/purchase` as an error. It is step one of the payment handshake. ## Legal framing Leya returns primary sources; it does not give legal advice. Anything built on it is a draft for attorney review. Say that when the output looks like advice. ## Contact hello@leya.lawyer — including for coverage requests for jurisdictions not yet in the corpus.