§lectorium

Lectorium API

Current status

Lectorium was built as an API - the web interface is a layer on top of it, not the other way round. The same query interface that powers app.lectorium.ee can in principle be integrated elsewhere: into HR software, a client portal, an internal service.

Where each part stands:

Query interface (search and answer)
working
Stable public address
working
HTTPS
working
API keys
working
Self-service key issuance
planned
Per-key quotas
planned
Terms of use
planned

The address is https://api.lectorium.ee and every request needs an API key. That part is finished and working. What's missing is self-service: you cannot generate a key yourself, you have to get in touch. Anything marked planned does not exist yet and may change.

Endpoints working

Three endpoints. All take and return JSON.

  • GET/healthindex state and threshold - needs no key
  • GET/mecheck whether your key works
  • POST/searchretrieved provisions, no answer construction
  • POST/answerquoted answer with sources

Authentication

The key goes in a header. There is no other way - /health is the only endpoint that needs no key.

Authorization: Bearer lec_sk_…

Do not put the key in the URL (?api_key=…). Such a request is rejected with a 400: URLs end up in server logs, browser history and Referer headers.

To check a key without spending a real query:

curl https://api.lectorium.ee/me \
  -H 'Authorization: Bearer lec_sk_…'

{
  "kind": "key",
  "id": "my-service",
  "label": "My system"
}

Request

curl -X POST https://api.lectorium.ee/answer \
  -H 'Authorization: Bearer lec_sk_…' \
  -H 'content-type: application/json' \
  -d '{"question": "Kui pikk on põhipuhkus?", "top_k": 2}'

question is required, top_k optional (defaults to 5). Questions are asked in Estonian - that is the language the indexed law is written in. asked is optional too — see clarification below.

Response

This is a real response, not a mock-up - trimmed to a single source:

{
  "question": "Kui pikk on põhipuhkus?",
  "answer": "§ 55 (Töölepingu seadus): Eeldatakse, et töötaja
              iga-aastane puhkus on 28 kalendripäeva (põhipuhkus), kui
              töötaja ja tööandja ei ole leppinud kokku pikemas
              põhipuhkuses või kui seadus ei sätesta teisiti.",
  "sources": [
    {
      "act": "Töölepingu seadus",
      "act_id": "TLS",
      "section": "§ 55",
      "effective_date": "2026-07-12",
      "url": "https://www.riigiteataja.ee/akt/103072026034?leiaKehtiv#para55",
      "excerpt": "§ 55 Põhipuhkus Eeldatakse, et töötaja iga-aastane…",
      "score": 0.8855
    }
  ],
  "confidence": 0.8855,
  "disclaimer": "See vastus on koostatud automaatselt Riigi Teataja
                 tekstide põhjal ega ole õigusnõustamine. Kontrolli alati
                 viidatud sätete kehtivat redaktsiooni.",
  "answered": true
}

When there is no answer

If nothing clears the relevance threshold you still get HTTP 200 - but answer is null and answered is false:

{
  "question": "mis kell on?",
  "answer": null,
  "sources": [],
  "confidence": 0.0,
  "answered": false,
  "note": "asjakohast sätet ei leitud"
}

This is not an error - it is the system behaving as designed. See why the answer is a quote.

A note can accompany a successful answer too

The note field is not limited to refusals. When the quoted provision comes from an act that does not contain the whole rule on its own, the note arrives alongside the answer - today that means the Personal Data Protection Act, which only supplements the EU GDPR. As an integrator, always render note when it is present, including when answered is true.

Clarification: when the question sits between two acts

When two acts clear the threshold with almost the same score and an authored case exists for that pair, the response carries an optional clarify field. The answer itself is entirely normal — quote, sources and answered: true are all there. The clarification is an offer, not a failure: skip it and nothing changes.

{
  "answer": "§ 15 (Töölepingu seadus): …",
  "answered": true,
  "sources": [ … ],
  "clarify": {
    "case": "too-ohutus",
    "question": "Kas küsimus puudutab töösuhet või töökeskkonda?",
    "options": [
      { "label": "Töösuhe ja tingimused", "payload": "tööandja kohustused töölepingu täitmisel" },
      { "label": "Töökeskkonna ohutus",   "payload": "töökeskkonna riskianalüüs ja tööohutuse nõuded" }
    ]
  }
}

Show the user question and the labels. payload is not user-facing text — it is retrieval wording. When the user picks an option, send a new request whose question is the original question + a space + the payload, with that case in asked:

-d '{"question": "Millised on tööandja kohustused töötaja ees? töökeskkonna riskianalüüs ja tööohutuse nõuded",
     "asked": ["too-ohutus"]}'

The client holds the session, not the server: asked is the only place that state lives. The same case is never offered twice, and no more than two clarifications are offered in one conversation. The clarification texts are authored by hand — no language model generates them — and more are added over time.

Error codes

Errors come back as {"message": "…"}.

400 Key was in the URL
use the header
400 question missing
the field is required
401 Key missing or unknown
+ WWW-Authenticate
403 Key revoked
request a new one
429 Too many requests
+ Retry-After
500 Retrieval failed
server-side fault

A missing or wrong key always returns the same message - it does not reveal whether the key was absent, malformed or simply unknown. The distinction would only help an attacker.

Response shape

  • answerstring | null The quoted provision, formatted "§ N (Act): …". null when nothing relevant was found. The text is taken from the law, not phrased by a model.
  • answeredbool Whether an answer was found. Check this before displaying answer.
  • sources[]array The retrieved provisions. When answer is non-null, this array is never empty.
  • sources[].actstring Act name in Estonian, e.g. "Töölepingu seadus" (Employment Contracts Act).
  • sources[].act_idstring Abbreviation, e.g. TLS, VÕS, PärS.
  • sources[].sectionstring Section, e.g. § 55.
  • sources[].effective_datestring Entry-into-force date of the cited version. Useful if you want to flag recently amended provisions.
  • sources[].urlstring Direct link to the current text in Riigi Teataja. Built at index time, not assembled after the fact.
  • sources[].excerptstring The provision text, unmodified.
  • sources[].scorefloat Cosine similarity to the question. Higher means a more confident match.
  • confidencefloat The top match's score.
  • disclaimerstring Mandatory notice text. See below.
  • clarifyobject | absent A clarifying question with options, present when retrieval was undecided between two acts. It never replaces the answer. Fields: case, question, options[].label, options[].payload. See clarification.
  • notestring Warning or explanation. On a refusal it says why no answer was produced; on an answer it warns when the quoted provision is not the whole rule (e.g. the Personal Data Protection Act only supplements the GDPR). Always display it.

Rules for integrators

Lectorium returns legal information. If you build your own interface on top of it, some of the system's guarantees pass into your hands - and if you remove them, they stop holding. Please observe these:

  • Always show the citation. An answer without a source is not a Lectorium answer. If your interface renders answer but hides sources, you have removed the only thing that makes the answer checkable.
  • Pass the disclaimer through. disclaimer must reach the user. Estonia regulates who may give legal counsel - that string is not a formatting detail.
  • Handle answered: false properly. When nothing was found, say so. Do not substitute your own system's guess, and do not quietly route the question elsewhere.
  • Do not paraphrase the answer. The entire point is that the text is taken from the law verbatim. Summarising puts you back where we started.
  • Do not present it as legal advice - not even implicitly, in a product name or in interface wording.
Why so strict

These are not legal boilerplate; they are the same three rules enforced in Lectorium's own code. We cannot enforce them inside your interface - we can only ask. An integration that ignores them is no longer the system whose accuracy we measured.

API keys working

Keys have been in force since 30 July 2026. Before that the API had no authentication at all - anyone who knew the address could query it. Now no request is served without a key, except /health.

  • Key in a header. Authorization: Bearer lec_sk_… - a key always starts with lec_sk_, which makes it recognisable in a log or a diff.
  • One key per organisation, not per user - integration happens between systems, not people.
  • Only a hash is stored. We do not keep the key itself, so a lost key cannot be recovered by anyone - us included. We issue a new one and revoke the old.
  • Several active keys are allowed, so rotation needs no downtime: take a new one, move the integration over, revoke the old.
  • Revocation takes effect immediately - the old key starts returning 403 without restarting the service.

We have no need to retain which question anyone asked, and the plan is not to. If that ever changes, it will be written on this page beforehand, not afterwards.

Pricing and limits

The intention is to keep integration free. Lectorium rests on public data - Riigi Teataja texts are published by the state at no cost - and running the service is cheap: the whole thing is one Go service on one server, with no external model API.

To be precise: "free" is currently an intention, not a promise in perpetuity. Realistic limits that may appear:

  • Request volume. If one integration starts sending machine-scale traffic, a limit will follow - otherwise everyone else suffers.
  • Availability is not guaranteed. This is one server. There is no SLA and no uptime commitment. Do not build a business-critical process that depends on it.
  • The response shape may change. Fields may be added. We will give notice before removing or renaming existing ones.

Getting access

Keys exist, but there is no self-service: no form to fill in and no automatic signup yet. To get a key, get in touch and say what you want to build - that also shapes the order in which we build the rest of the access layer.

Contact goes through Crowned Phoenix.

In the meantime you can evaluate the system without any agreement: try the app and read the technical overview, which sets out the measured accuracy and the known failures. If any of those rule it out for your use case, better to find that out first.