API/api/v1 · lekta-score/2026.7

The Lekta HTTP API

Three JSON endpoints and one public badge endpoint. GET only, one static API key, server to server. An audit is asynchronous: you start it and poll the same URL until it answers. The report JSON is byte-identical to what the MCP server and the member web view return — one engine, one shape, lekta-score/2026.7.

GET onlyBearer or x-api-keyapplication/jsonlekta-score/2026.7

Authentication

401 · lekta_

Every JSON endpoint takes the same key, and all three read it the same way. Two header forms are accepted, checked in this order:

  1. Authorization: Bearer <key>
  2. x-api-key: <key>
The prefix test is case-sensitive

The server tests the Authorization header with startsWith("Bearer ") — capital B, exactly one space. A header spelled "bearer" does not match and falls through to x-api-key; with no x-api-key header present you get a 401 that looks like a bad key. This is the single most common client bug. An x-api-key made only of whitespace collapses to no key at all.

Key format and storage

  • A key is the literal prefix lekta_ followed by 40 base64url characters — 46 characters in total. Anything not starting with lekta_ is rejected before any database round trip.
  • Only the SHA-256 hash of the key is stored, plus the first 14 characters as a display prefix. The raw key is shown once, at generation, and cannot be recovered.
  • One key per account. Generating a new key overwrites the hash and kills the old key immediately — there is no rotation window, no scopes, no expiry and no separate revoke.
  • No cookie or session auth on any /api/v1 endpoint, no Turnstile (the key is the identity) and no OAuth anywhere.

Auth fails closed

A missing key, a malformed key, an unknown key, an unconfigured database and a database error during lookup all produce the same 401. An unconfigured backend is indistinguishable from a bad key — do not read a 401 as proof that the key is wrong.

401 body, verbatim:

{
  "error": "invalid_api_key",
  "hint": "Authorization: Bearer lekta_... or x-api-key: lekta_... (get a key on /en/panel/api)"
}
Bearer form
curl -H "Authorization: Bearer lekta_..." \
  "https://lekta.dev/api/v1/me"
Header form
curl -H "x-api-key: lekta_..." \
  "https://lekta.dev/api/v1/me"

Endpoints

4
EndpointWhat it doesSpends quotaFetches the target
GET /api/v1/meKey check, plan, quota countersnono
GET /api/v1/auditStart or poll an audit of one URLyesyes
GET /api/v1/auditsGrade drops on your watched sitesnono
GET /api/badge/{token}Public SVG grade badge, no keynono

These four are the complete public HTTP surface under /api. The only other machine surface is the MCP endpoint, described at the end of this page.

GET /api/v1/me

200 · 401

The correct connection test: it tells you who the key belongs to and how much quota is left, and it costs nothing.

No parameters. The query string is ignored entirely.

200 body:

{
  "email": "you@example.com",
  "plan": "free",
  "quota": {
    "perDay": 15,
    "usedToday": 3,
    "remaining": 12,
    "perDomainPerDay": 10
  }
}
200 body
emailThe account e-mail. Used as the connection label in Zapier.
plan"free" or "pro".
quota.perDayDaily audits for the plan.
quota.usedTodayAudits today from the website and the API together. MCP audits are counted separately and are not included.
quota.remainingmax(0, perDay − usedToday).
quota.perDomainPerDaySame-domain audits allowed per day.
  • The key is never echoed back, not even its prefix. Account id, display name and avatar are deliberately not exposed.
  • Costs nothing: starts no audit, spends no quota, makes no external request. Two database reads.
  • Keeps returning 200 after the daily quota is exhausted. This is deliberate — a connection test must not break at the limit.
  • No rate limit of any kind on this route. Once-a-minute polling is the intended usage.
  • usedToday is written when a run completes, not when it starts, so two rapid calls around a running audit can report the same number.
  • If the count query fails it returns 0, so the response reports full remaining quota with no flag. It is silently optimistic.
  • Read your limits from here rather than hardcoding them: they are per-plan and environment-tunable, so they differ between deployments.

GET /api/v1/audit

200 · 202 · 400 · 401 · 429

Parameters

url (query). Absent behaves like empty and returns 400. It is the only parameter read — unknown query parameters are ignored. Normalisation runs before anything else:

  • The input is trimmed. Empty input is a 400.
  • A missing scheme is prefixed with https://, so example.com/x is accepted.
  • It must parse as a URL, and the protocol must be http: or https:.
  • The hostname must contain a dot, or be exactly localhost.
  • The fragment is stripped, the hostname is lower-cased and trailing dots are removed, so example.com. and example.com share one rate-limit bucket.
  • The query string is kept and is part of page identity: ?a=1 and ?a=2 are two different audits — but the same host bucket for rate limiting.
  • The normalised URL is the cache key, the rate-limit key, and the value echoed back as report.url.

The poll loop

  1. Call GET /api/v1/audit?url=… with your key.
  2. A fresh target answers 202 {"status":"running","retryAfterSec":5} with a retry-after header carrying the same number.
  3. Repeat the identical request until you get a 200.
  4. 200 gives you either {"status":"done","report":…} or {"status":"error","error":…}.

There is no job id, no status URL, no callback and no webhook. The normalised URL is the handle, and a second caller asking for the same URL joins the same run. The server deliberately does not wait for the run inside the request. Poll at the returned retryAfterSec, and give your HTTP client a timeout above 75 seconds for the run itself.

curl
curl -H "Authorization: Bearer lekta_..." \
  "https://lekta.dev/api/v1/audit?url=https://example.com/pricing"

Status codes

StatusBodyWhen
200{"status":"done","report":…}Report cache hit — checked before the rate limit and before quota — or the run finished inside this request.
200{"status":"error","error":…}Error cache hit (60 s) or the run failed. Audit failures are HTTP 200, never 5xx.
202{"status":"running","retryAfterSec":5}A fresh audit started, or you joined one already in flight.
400{"error":"invalid_url","reason":…}Normalisation failed. The four reasons are listed below.
401{"error":"invalid_api_key","hint":…}See Authentication.
429{"error":"target_rate_limited","retryAfterSec":n}The per-target limiter refused. The retry-after header carries the same number, falling back to 60 if the limiter supplied none.
429{"error":"daily_quota_exceeded","limit":n}Today's audits reached the daily allowance. retry-after: 3600.
429{"error":"domain_quota_exceeded","limit":n,"host":"…"}Today's audits of that hostname reached the per-domain allowance. retry-after: 3600.

This handler produces no 5xx of its own. Every response carries content-type: application/json; charset=utf-8 and cache-control: no-store.

The four invalid_url reasons

These strings are returned verbatim, in English, in both languages of this page — they are what the wire carries.

reason
emptyEmpty input. Enter a URL like https://example.com/page.
unparseableThe input could not be parsed as a URL.
schemeOnly http(s) targets can be audited (got ftp).
hostThe host name looks incomplete (no dot). Did you mean a full domain?
Two shapes, no shared envelope

Success bodies key off status. Error bodies key off error. The two never appear together, and there is no unified envelope. A client that branches only on the HTTP status will read a failed audit as a success, because {"status":"error"} arrives with HTTP 200.

The error object

  • cause: "engine" — the message is always the same fixed sentence. The real error is logged on the server and never forwarded: “The audit could not complete on the server side. Try again in a minute.”
  • cause: "timeout" — the message is the engine's own text, naming the 75 s limit and the phase it died in (queued, static-fetch, robots, render or scoring).

The report object

Returned inside {"status":"done"}. The same object is what lekta_report returns over MCP and what the member web view renders.

report
engineVersion: stringScoring engine that produced this report. Currently lekta-score/2026.7.
url: stringThe normalised request URL — the cache and rate-limit key.
finalUrl: stringWhere the fetch actually landed after redirects.
fetchedAt: stringISO 8601, UTC, with a trailing Z.
durationMs: numberWall-clock time the run took.
targetState: objectTagged union on kind. Anything other than ok carries a detail, and the HTTP ones carry a status.
robotsDisallowsLektaBot: booleanWhether the target robots.txt disallows our own crawler on this path.
layers: arrayThe 4 scoring layers, each with its definition, its score and its checks.
score: number | nullNull exactly when the target could not be measured — the grade is then U.
grade: stringOne of the grades listed below.
coverage: objectmeasuredWeight, totalWeight, partial, and layerGap when a layer went unmeasured.
appliedCaps: arrayCeilings that were applied, each with capTo, reason, checkId and basis.
movers: object?What moved the score. Optional — absent on older stored reports.
degraded: true?Only ever written as true, when a rule crashed. Otherwise the key is absent.
warningCount: numberChecks with status warn. One warning is enough to keep a page off A+.
failCount: numberChecks with status fail.
coverageNote: stringPlain-language statement of what was and was not measured.
fetchMeta: objectstatus, redirectChain, contentType and bytes of the fetch itself.

The check object

check
id: stringStable rule id. Safe to key on.
title: stringHuman title of the check.
status: stringOne of the check statuses listed below.
severity: stringOne of the severities listed below.
metric: string?What was measured, when the check is continuous.
scoreValue: number?Partial credit on continuous checks. Binary checks never carry one.
evidence: arrayThe measured values, each {label, value, mono?}.
fix: string?What to change. Delivered in full — nothing is truncated for the API.
basis: objectWhy the check exists: {tag, date} only. See the redaction note below.
data: object?Structured extras for the few checks that carry them.
msg: object?Message key and params, for clients rendering their own copy.

Closed vocabularies

enum
gradeA+ · A · A- · B · C · D · F · U
check.statuspass · fail · warn · info · not_applicable · unmeasured
check.severitycritical · serious · moderate · minor
basis.tagSPEC · VENDOR · MEASUREMENT · PEER-REVIEWED · PROVISIONAL
targetState.kindok · unreachable · blocked · auth-required · redirect-loop · http-error
layers[].def.idaccess (25) · indexability (25) · answerability (30) · recency (20)

What never leaves the server

One central filter is applied identically to this API, the MCP server and the member web view. basis.source, basis.url, basis.note and capSuggestion never leave the server: basis carries only tag and date. The fix text is delivered in full.

What is not an error

A redirect loop, an HTTP error, a blocked target or one that demands authentication still returns 200 {"status":"done"} with a report whose grade may be U and whose score may be null. That is a successful audit of an unhappy target, not a failed request.

What a call costs

A fresh run spends one audit from the shared web + API daily pool, starts the engine and reaches the target over the network — its robots.txt, its HTML, and a headless render. None of that happens on the cache-hit, error-cache-hit, 401, 400 or 429 paths, and repeated 202 polls of the same URL add nothing. Quota is counted at completion, not at start, so a caller one below the limit can have several runs in flight.

GET /api/v1/audits

200 · 401

Grade drops on the sites you watch in the panel, newest first. Built for once-a-minute polling; this is what the Zapier "Grade Dropped" trigger calls. It costs nothing: no quota, no audit, no external request, one database read, and no rate limit.

limit (optional). Absent, non-numeric, zero or negative all fall back to 100; fractions are floored and larger values are clamped. The default and the maximum are both 100. There is no cursor, no offset and no since parameter. The only statuses are 200 — including an empty list — and 401.

[
  {
    "id": 4821,
    "url": "https://example.com/pricing",
    "prevGrade": "A",
    "newGrade": "B",
    "prevScore": 92,
    "newScore": 78,
    "prevEngineVersion": "lekta-score/2026.7",
    "engineVersion": "lekta-score/2026.7",
    "prevAt": "2026-09-08T09:12:44.000Z",
    "at": "2026-09-09T09:14:02.000Z"
  }
]

The 200 body is a bare JSON array, not an object. Some clients and schema validators assume a top-level object; changing this would break the Zapier trigger, so it will not change. id is the audits row id of the new run: unique, immutable, and the intended deduplication key.

Semantics worth reading twice

  • Only drops appear, and only between two consecutive authoritative runs. A run is authoritative when the target state is ok and the report is not degraded. Non-ok and degraded runs are excluded from the comparison entirely, so prevGrade is the last valid grade, not literally the previous row.
  • U is deliberately not ranked. Any transition involving U is not a drop and never appears. The order is A+ > A > A- > B > C > D > F.
  • Only watched sites appear. Ad-hoc audits of URLs you have not saved never show up here.
  • Receiving fewer than limit items does not mean there are no more. The route reads 500 transitions, filters them down to drops, then slices. An account with more authoritative transitions than that loses older drops silently.
  • A database failure looks exactly like quiet: the query catches its own exception and returns an empty array, so 200 [] is indistinguishable from an outage. Poll accordingly.
  • prevScore and newScore can be null even on a listed drop.

GET /api/badge/{token}

200 · 404
  • Public — no key. The path token is the entire credential: a site owner publishes their grade by embedding it. Rotating or clearing the token in the panel 404s the old one instantly.
  • Token shape is 10 to 64 characters of A-Z, a-z, 0-9, underscore and hyphen. No query parameters are read.
  • 200 returns image/svg+xml — an inline badge with a title element. With no authoritative run it reads "not audited"; otherwise it shows the grade and the earned points, and adds the measurable total under partial coverage.
  • 404 returns text/plain with the body "not found" for a malformed token, an unknown token or an unconfigured database. 404 bodies are not JSON — a client parsing them as JSON will throw.
  • cache-control: public, max-age=3600. This is the only endpoint here that shared caches and CDNs may serve, so a grade change can take up to an hour to reach the badge. 404s carry no cache-control.
  • It shows the last authoritative run, so a transient failure does not blank the badge to U. It leaks grade, score and partial-coverage points to anyone holding the token, but no findings. That is intentional.

Error codes

5
CodeHTTPWhereExtra fieldsRetry helps
invalid_api_key401all three JSON endpointshintno — fix the key or the header
invalid_url400/api/v1/auditreasonno — fix the URL
target_rate_limited429/api/v1/auditretryAfterSecyes, after the given wait
daily_quota_exceeded429/api/v1/auditlimitnot today
domain_quota_exceeded429/api/v1/auditlimit, hostnot today, for that host
  • There are no x-ratelimit-* headers of any kind. retry-after is the only rate signal, and it appears only on the 429s and the 202.
  • The human sentences the limiter computes internally are not in the JSON. Only error and retryAfterSec are.
  • Failure modes diverge by layer, and a client should know which: auth fails closed, into a 401. Quota fails open — the gates simply do not fire when the counter cannot be read. Persistence fails open, so a run can succeed, be returned, and never be counted or listed. The drop list fails open to an empty array.

Limits

3

Three independent layers. A call has to pass all three.

Per target host — global across all customers

  • 1 audit per minute per hostname.
  • 5 audits per rolling hour per hostname.
  • Concurrency 1 per hostname; a refusal while one is running asks you to wait 15 seconds.
  • This layer exists to protect the audited site, not to sell a tier — which means another customer auditing the same host can 429 you.
  • The exception that makes polling work: when that exact normalised URL is already in flight the limiter lets you through. A different URL on the same host during a run is still refused.

Per account, per day

  • Free: 15 audits a day, 10 per domain. Pro: 500 a day, 200 per domain.
  • The API shares one daily pool with the website. An audit you run in the browser reduces what the API can do that day.
  • The counter resets at 00:00 UTC.

MCP — a separate pool

  • Free: 10 fresh audits a day across 1 site. Pro: 200 a day across 50 sites.
  • Cached reads, diffs and fix plans are free.
  • Neither pool drains the other.
In-memory, per process

The result cache, the error cache, the in-flight map and the per-target limiter all live in the web process's memory. They do not survive a restart or a redeploy and are not shared across instances. Every cache window and rate-limit window on this page is a best-effort behaviour of one process, not a distributed guarantee. The hard per-audit time limit is 75 seconds. All of these are tunable per deployment, so GET /api/v1/me is the authoritative reading of your own limits.

Caching and freshness

15 min · 60 s
  • Report cache 15 minutes, error cache 60 seconds, both keyed by the normalised URL, oldest entry evicted past 500 entries.
  • A cache hit is free: no quota, no rate limit, no network.
  • At the HTTP level every JSON response is cache-control: no-store. The window is a server-side de-duplication window only; nothing is cached downstream.
  • A degraded report — one produced while a rule crashed — is kept but never served. The next call starts a fresh run.
  • All timestamps in JSON are ISO 8601 UTC with a trailing Z.

Other machine surfaces

/mcp

A stateless streamable-HTTP MCP endpoint speaking JSON-RPC 2.0 at https://lekta.dev/mcp. initialize, ping and tools/list need no key; tools/call takes the same key with the same two header spellings. Five tools:

tools
lekta_auditAudit one URL and return a ranked verdict. Spends a fresh MCP audit.
lekta_reportThe whole report as JSON — the same object documented above.
lekta_fix_planThe ranked fixes for a URL already audited. Free.
lekta_diffWhat changed between two runs of the same URL. Free.
lekta_my_sitesThe sites saved on the account. Free.
  • Sending no key at all returns HTTP 200 with a tool-level error, not a 401. A 401 sent MCP clients into an OAuth discovery flow that crashed them. A key that is present but invalid does return a real 401.
  • CORS is asymmetric: /mcp answers OPTIONS and sends access-control-allow-origin: *. /api/v1/* sends no CORS headers and has no OPTIONS handler — it is server to server, and browser cross-origin calls will not work.
  • /api/ and /mcp are excluded from the language-prefix middleware. API callers need no locale prefix and must not follow one; API responses carry no content-security-policy header and set no language cookie.
Browsers

Do not call /api/v1/* from a browser. There are no CORS headers, and your key would be in the page anyway.

Scope, stability and contact

lekta-score/2026.7

These are production endpoints. https://lekta.dev is the only environment: there is no sandbox, no staging and no separate developer host. A test account audits real URLs and spends real quota.

The API calls no third-party vendor API in the request chain. A run fetches the target URL you supplied — its robots.txt and its HTML, identifying itself as LektaBot — and nothing else.

Our Zapier integration uses these same endpoints: the "Grade Dropped" trigger polls /api/v1/audits and deduplicates on id, the "Audit a URL" action calls /api/v1/audit and spends daily quota, and the connection test calls /api/v1/me.

GET only. New response fields may ship without notice, so parse tolerantly. Removals and shape changes are announced in the changelog, and every score-affecting engine change ships with a measured shift table.

Contract last verified
Get an API keyPricing and limitsCrawler policyEngine changelogContact
API reference — Lekta