# API reference

Base URL: `https://api.jepela.com` (the API of your key's region; see Regions). Every `/v1` route except sign-up needs `Authorization: Bearer <key>`, and every POST `Content-Type: application/json`. A POST body may be compressed with `Content-Encoding: gzip` (or `x-gzip`) or `zstd`, one layer, as TypeSafe's newer SDKs send it. Bodies are capped at 1 MB (16 MB for `/v1/batches` and `/v1/golden`); a compressed body counts once decompressed. Duplicate JSON keys are rejected, and a body nested more than 100 levels deep (arrays and objects; in a JSON Lines batch, each line) is refused with a 400. Connections stay open between requests (HTTP/1.1).

## POST /v1/systemone

Request:

| Field | Type | Meaning |
|---|---|---|
| `state` | string, object or array (required) | what to evaluate; see State for how much of it the engine reads |
| `questions` | object of question id to question (required) | at least one |
| `model` | string | a name from `/v1/models`, or this tenant's fine-tuned `jepela-ft-*`; default `jepela` |
| `subject` | string, 1 to 36 of `A-Za-z0-9_-` | use this subject's memory and rules |
| `memory` | object | `use` (default true with a subject), `top_k` (1 to 20, default 6), `min_share` (0.05 to 1, default 0.2), `compare` (default false), `placebo` (default false), `names` (default true: the subject's name and aliases are searched with the state), `question_words` (default true: the questions' instructions are searched too), `focus` (default false: keep only lines that share a word with the state beyond those names), `explain` (default false: per memory line, the answer's move when that line alone is left out; one engine pass per line, at most 8). `meaning` is internal, not a supported option: it is accepted as true or false and is off unless sent, but it may change or be refused without notice, so do not rely on it |
| `quality` | `"fast"` (default), `"balanced"` or `"best"` | speed or accuracy: above fast, the answers the engine is least sure of, those whose state it could not read in full and every answer of a decision with memory or computed facts are answered again by a larger, slower second tier; best sends more of them (Request options) |
| `method` | `"auto"` (default), `"engine"` or `"match"` | how choice questions are answered: auto matches choices with more than 20 options by vectors and asks the engine the rest; engine reads every option; match matches every choice (Choice) |
| `robust` | `true` or `{"orders": 1-5}` | ask each choice of 2 to 20 options in several option orders and average them; `true` is 3. A choice of more than 20 options is matched (or, with `method: "engine"`, answered in two rounds) and is not reordered |
| `windows` | `true` or `{"combine": {"question_id": "max" \| "min" \| "mean"}}` | read a state that is cut in overlapping windows (32 tokens shared), at most 16 |
| `derive` | object, at most 20 names | facts computed in code: `{"name": "expression"}` or `{"name": {"when": "expression", "fact": "text", "else": "text"}}`; a name starts with a letter and has at most 40 letters, digits or underscores; an expression has at most 300 characters, a fact at most 500 |
| `values` | object, at most 50 fields | numbers and text that `derive` and rules may use, read before a field of the state with the same name; `today` sets the date `days_since` counts from |
| `cache` | boolean | an identical request that does not use a subject's memory returns the stored answers: from the same engine build, at most 24 hours old, removed by `POST /v1/decisions/delete`; a repeat is answered while the engine is briefly away (Request options) |

Questions: `{"type": "choice", "instructions", "criteria": {name: description}}` with 1 to 255 options (more than 20 are matched by default; see `method`); `{"type": "score", "instructions", "criteria": [level, ...]}` with 2 to 10 levels, lowest first; `{"type": "noul", "instructions", "criteria"?: {"true", "false"}}`. `instructions` and descriptions may be strings, objects or arrays. A question whose `depends_on` is set to anything but null or an empty value is refused with a 422: every question is answered on its own, so ask the questions together and apply the condition in your code. A request may need at most 400 engine questions, counting every option order, every group of a two-round choice and its final round; matched choices are not engine questions. `context` is not accepted: Jepela builds it from the subject's memory and `derive`.

Fields Jepela does not read: `assets` and `images` (pictures, which Jev takes), in the request or in a question, are refused with a 422 when they hold anything, before anything is asked or charged, because Jepela reads text only and an answer from the text alone would look as if the pictures had been seen; describe what they show in the state. Empty ones pass. Any other field Jepela does not read, at the top of the request, in `memory` or in a question, is answered as usual, without that field, and the answer carries an `unknown_field` warning that names it and the closest field Jepela reads (a question's warning has `question`): a misspelt `subjet` gives "unknown field subjet; did you mean subject?" on a decision made without memory, and `memory.comapre` gives no comparison and "did you mean memory.compare?"; a noul's `critera` is a question without its descriptions and "did you mean questions.refund.criteria?". A question's fields are those TypeSafe's API reference documents (`type`, `instructions`, `criteria`; and `depends_on`, above), so code written for Jev keeps working, and a typo is seen. A request refused for a missing field names the misspelt one: "choice needs 1 to 255 named candidates (unknown field questions.route.criterias; did you mean questions.route.criteria?)". A name that looks like a key or a token is never repeated in a warning. A request with an unknown field is billed like the same request without it.

Response:

| Field | Meaning |
|---|---|
| `decision_id` | `dec_` and 24 hex digits; send it back with `/v1/feedback`. The state is never stored |
| `model` | the name asked for, aliases resolved |
| `engine` | `name`, `checkpoint` (the one that answered), `runtime` (the engine's metadata: `version`, the Jepela build id, which is the hash of the engine's code and changes whenever the engine does; `device`; timings) |
| `answers` | per question, each with `type`, `confidence` (1 minus the normalised entropy of the probabilities for a choice or score; the larger of yes and no for a noul), `answer_confidence` (the probability of the answer given, on one scale for every type: the chosen option's, the most likely level's, or the larger of yes and no; always the top value of the answer's own probabilities, after a tenant temperature too), `method` (`engine` or `match`; matched answers have `similarity`) and, on an answer the engine read (`method` `engine`), the engine's `action` (not on matched answers or on answers the second tier gave, nor in their `first`; do not decide on it; see below the table). choice: `choice`, `probabilities`; `rounds` and `groups` above 20 options; `orders` and `agreement` with robust. score: `score` (probability-weighted level), `probabilities`, `legend`. noul: `noul` (probability of yes). With a tenant temperature: `calibration` and the `raw_` values before it. With windows: `window`. With `quality` balanced or best: `right_probability` (the estimate that the engine's answer is right), and on an answer the second tier gave `tier` (2) and `first` (the engine's own answer) |
| `warnings` | known traps in this request: `{code, message, question?}`; see Warnings |
| `reading` | per question: `state_tokens`, `read`, `room`, `chars_read`, `truncated` (counted on the state with memory and computed lines in front; `chars_read` counts from the start, or for an array, which is cut from its start, the last characters; with windows, `read` and `chars_read` count the tokens and characters of the state the windows covered: from its start, or for an array too long for the windows, which are then placed at its end, up to its end); `context_lines_read` when such lines were placed; `turns_dropped` when Jepela left out that many of an array's oldest elements so that the lines placed before it were read (then `truncated` is true, and `read` and `chars_read` count what was sent, the lines and the newest elements, read whole); `windows` when windows read the whole state; for a matched choice, what the matching model read, with `method: "match"` and counted with its tokens (see Choice) |
| `derived` | the value of every `derive` name |
| `memory` | with a subject: `subject`, `lines_used` (fired rules and recalled lines sent to the engine), `lines_seen` (how many of them it read: the first ones, or in an array, which is cut from its start, the last ones), `lines_dropped` (left out of its 64 context lines), `hits` and `rules_fired` (each line with `read`: whether the engine read it, as many true as `lines_seen`), `rules_broken` (rules not checked: `id`, `error`, and `missing`, the field, when the request had no field the rule reads), `recall_ms`, `without_memory` and `changes` (with compare, otherwise null: the same call without the memory and its rules, on the same elements of an array when Jepela left out its oldest, and per question what changed; a choice's entry has `excluded` when exclusion rules removed options), `placebo` (with placebo: `lines`, `answers`, `content_effect`, `memory_effect`, `memory_distance`, `placebo_distance`, `presence_only`; where the second tier gave the answer, `measured_on_first` lists the questions whose effects are measured on the engine's own answer `first`: see Memory); when they apply: `pinned` (ids of pinned rules sent), `excluded` (per question, the options removed by exclusion rules and why), `exclusions_skipped`, `explain` (a list of `{line, kind, read, effect}`; where the second tier gave the answer, `measured_on_first` lists the questions whose effect is the move of the engine's own answer `first`: see Memory) |
| `quality` | with `quality`: `level`; above fast also `below` (the `right_probability` under which an answer is answered again), `asked_again` (question ids), `why` (per question: `unsure`, `cut` or `memory`) and, when the second tier gave no answer in time or failed, `fallback` (`questions` that kept the engine's own answer, marked `tier: 1`, and the `reason`) |
| `windows` | when the state was read in windows: `count`, `covered_all` (whether the windows reached the end of the state), `state_tokens`, `tokens_per_window`; each answer's `window` names the window it came from (null with `mean`) |
| `usage` | `questions`, `answers`, `answers_billed`, `engine_answers`, `input_characters`, `input_tokens` (the engine's count, what you pay for), `output_tokens` (always 0), `state_tokens`, `state_tokens_read`, `state_truncated` (over the questions the engine read; a matched choice's own reading stays in `reading`), `memory_lines_used`, `memory_lines_seen`, `match_questions` (when some were matched), `tier2_questions` and `tier2_input_tokens` (when the second tier answered), `cached`, `engine_ms`, `gateway_ms`, `cost`, `currency`, `balance`. `answers_billed` is the sum over questions of the option orders used (1 without robust) × windows; the compare, explain and placebo passes are not billed |

`action` is `{"act_probability": <number>}`, an output of the engine that Jepela passes through as it comes and reads nothing from; it is not one of the answer's probabilities. On all 40 answers recorded on 2026-09-23 ([Examples](examples.md)) it was 1.0: for choice, score and noul questions alike, with and without memory, at confidences from 0.016 to 1.0. So it tells you nothing about an answer: do not decide on it. Decide from `probabilities` and `confidence` ([Confidence](confidence.md)), or, once you have labels, from the thresholds `GET /v1/quality` gives in `automate_at` ([Quality](quality.md)).

## Memory

| Route | Body or query | Response |
|---|---|---|
| `POST /v1/memory/remember` | `subject`, `text` (one fact per line, each at most 2,000 characters; a subject holds at most 10,000 lines) | `lines_added` (the lines it did not hold yet: a line the subject holds is not stored again), `lines_stored`, `warnings`, `usage` |
| `POST /v1/memory/forget` | `subject`, `concept`, `preview` (true: nothing is removed and the answer says what would go, with `"preview": true`, no `usage` and no `verified_forgotten`; not billed, no ledger line) | `lines_removed`, `removed_lines` (their text), `rules_removed`, `removed_rules` (`id` and `text` of each), `matched_unsure` (`lines` and `rules` removed although the case does not show the name there: the first word of a sentence, a list item or a table cell, a word or a heading in capitals or title case, or a name of several parts in another case; or a word of the concept matched only with other accents or none, "José" for "Jose", "Jose" for "José", or with a letter such as ø, æ or ł written without it, "Søren" for "Soren", "Kjaer" for "Kjær"), `related` (`lines` and `rules` kept that may be about a name: they hold the concept with a name in another case, "Billing is per month." for "Per", or a part of a name of several parts, "Ann prefers email." for "Ann Lindqvist"), `verified_forgotten`, `leaks`, `usage`, `warnings`; a line or rule goes when it holds every content word of the concept as a whole word, in any script ("Ann" is not in "annual"; "MÜLLER" is "Müller"): a word written in lowercase with case ignored, also as a part of a hyphenated word that holds a letter ("dairy" in "dairy-free", not "2026" in "2026-10-16"), a word written with a capital letter (a name) only where the line has a capital too ("Per" is not in "per month"), and also in its possessive and genitive forms ("Ann's", "Åsas") and as a part of a hyphenated name ("Smith" in "Smith-Jones"), with the apostrophes inside it, however typed ("O'Brien" is "OʼBrien", not "Brien") |
| `POST /v1/memory/delete` | `subject` | `lines_removed` (the lines the subject held), `batch_requests_deleted` (the subject's requests taken out of your batches, with their results), `usage`, `warnings`; the lines and rules on disk go too. Any other field with a value is refused (422) and nothing is deleted |
| `GET /v1/memory` | `subject`, `lines=true` | `lines_stored`, `rules`; with `lines=true` also `lines` and `rule_list` (`id`, `when`, `text`, `expires_at`), word for word |
| `POST /v1/memory/rules` | `subject`, `when` (at most 300 characters; optional with `pin` or `exclude`, then always), `text` (at most 500), `pin` (true: the line is sent at every decision, first), `exclude` (1 to 20 option keys removed from choice questions in code when the rule holds; its text is never sent to the engine), `question` (limit an exclusion to one question id; refused without `exclude`), `expires` (ISO date or time) or `ttl_seconds` (at most ten years); at most 100 rules per subject | `rule` (with `expires_at`), `rules` (how many), `usage`, `warnings`; billed as one memory line |
| `POST /v1/memory/aliases` | `subject`, `aliases` (at most 20 names, 1 to 80 characters each; an empty list clears them) | `aliases`, `searched_as` (the words recall adds to the state), `warnings` |
| `POST /v1/memory/aliases/delete` | `subject`, `aliases` (the names to take out, at most 20, matched in any case; required, an empty list removes nothing) | `removed` (the aliases that went, as stored), `not_found` (the names the subject did not hold), `aliases` (those left), `searched_as`, `warnings`; adds no name; not billed |
| `GET /v1/memory/rules` | `subject` | `rules` |
| `POST /v1/memory/rules/delete` | `subject`, `rule_id` | `deleted`, `rules`, `warnings` |

Fields a memory route does not read get the policy of a decision: the request is carried out without the field, and `warnings` has an `unknown_field` warning naming it and the closest field the route reads, or the route that reads it: a rule with `ttl_secs` is stored without an expiry and "unknown field ttl_secs; did you mean ttl_seconds?" says so; `text` sent to `forget` is "read by POST /v1/memory/remember and POST /v1/memory/rules, not here". A field that would change what is done and cannot be honoured is refused with a 422 before anything is stored or deleted, and nothing is charged: `remember` with `when`, `pin`, `exclude`, `question`, `expires` or `ttl_seconds` (or a name close to one: a remembered line is recalled by its words, always and until forgotten; send it as a rule), `delete` with any field but `subject` (it deletes everything about the subject, more than such a field asks for), `forget` with a name close to `preview` in any case or spelling (`Preview`, `PREVIEW`, `preview_only`), `dry_run`, `dry run`, `simulate`, `simulation`, `what_if` or `noop`, or an object that holds one (`"options": {"preview": true}`), or with any query string (`POST /v1/memory/forget?preview=true`; the caller asked only to look; send `"preview": true` in the body), and a rule's `question` without `exclude`. Every route that deletes reads only its JSON body and has no preview but `forget`'s: a query string on `forget`, `delete`, `rules/delete`, `/v1/golden/delete`, `/v1/decisions/delete` or `/v1/finetune/delete` (`?dry_run=true`) is refused with a 422, and so is a field that asks `rules/delete`, `/v1/golden/delete` or `/v1/finetune/delete` only to look (a name like `preview` or `dry_run`, as for `forget`, or an object holding one); nothing is deleted or charged. A field whose value is null, false or empty asks for nothing and is only named. `customer`, the older name of `subject`, is read everywhere. A 422 for a missing field names the misspelt one the body carried: "concept must not be empty (unknown field concpt; did you mean concept?)".

Removing data never waits for money: `forget` (and its preview), `delete`, `rules/delete` and `aliases/delete` work when the balance is zero or below, and while the account is suspended (a deletion request suspends it until it is erased), as do the golden, decision and fine-tuned model deletes below. A suspended account can also read its data on every `GET` route (`GET /v1/memory` with `lines=true`, the rules, golden cases, batches, fine-tuning jobs and usage), so it can still answer a data subject's request for access; it cannot store, change or decide anything. An erased account or a revoked key can do none of this. A `forget` or `delete` is charged by the prices while the balance covers it; when it does not, it is carried out all the same, its ledger line says `cost` 0, and `usage.unbilled` says what it would have cost, so removing data never takes the balance below zero.

## Labels and quality

| Route | Body or query | Response |
|---|---|---|
| `POST /v1/feedback` | `decision_id`, `question`, `correct` (an option, a level, or true/false) | `label`, `correct`, `labels_for_this_question`; `calibration_refit` at 50, 100, 200 … labels |
| `GET /v1/quality` | `question`, `days` (default 30), `model`, `source` (`feedback` or `golden`) | per question, model and `tier` (2 for the second tier's answers): `labels`, `accuracy`, `calibration_error`, `brier`, `log_loss`, `bands`, `automate_at` |
| `POST /v1/calibration/fit` | `model`, `question`, `source`, `min_labels` (at least 20, default 50) | `results`: per question, and per group of questions with the same type and number of options, the fitted temperature and whether it was kept |
| `GET /v1/calibration` | | `temperatures` in use |
| `POST /v1/golden` | `cases`: `{id, state, questions, expected, subject?, values?, derive?, memory?, robust?, windows?, method?}`; `id` is 1 to 64 characters, and a case with an existing id replaces it; `customer`, the older name, is stored as `subject` (`subject` wins when both are sent); `context`, `assets` and `images` are refused as in a decision | `added`, `cases` (at most 5,000 per tenant), `warnings` (an `unknown_field` warning, with `case`, for each field a case is not kept with) |
| `GET /v1/golden` | | `count`, `cases` (ids, questions, expected), `runs` |
| `POST /v1/golden/run` | `model` (default `jepela`) | `run_id`, `cases`, `accuracy`, `questions`, `changed_since_last_run` (null on a model's first run), `failed` (each case that failed or was not run, with the reason); billed like decisions; a case that fails, or is not run because the credit ran out, is left out of the accuracy and not billed |
| `POST /v1/golden/delete` | `{"ids": [...]}`, or `{}` for every case (an empty list deletes nothing); without `ids`, any other field with a value is refused (422) and nothing is deleted | `deleted`, `warnings` |
| `POST /v1/finetune` | `base` (`english` default, `multilingual`, `typed-decisions`); trains on your golden cases, at least 20 | the job: `job`, `model`, `cases`, `held_out`, `state`; 409 while another of your jobs runs or the engine is off. See [Fine-tuning](fine-tuning.md) |
| `GET /v1/finetune` | | `jobs`, `min_cases`, `bases` (the bases the engine serves now) |
| `GET /v1/finetune/<job>` | | `state` (`running`, `saving`, `done`, `failed` with `error`), `result` (`won`, `base_accuracy`, `tuned_accuracy`, `held_out_questions`, log losses, `training_seconds`), `usable` |
| `POST /v1/finetune/delete` | `model` | `deleted`; its files and its name are gone |
| `POST /v1/decisions/delete` | `{}`; a field with a value is refused (422) and nothing is deleted | `decisions_deleted` (question records), `labels_deleted`, `batches_deleted`, `warnings`: every stored decision record and label of the tenant, and every batch with its requests and results (a running one stops); the answers the cache keeps for the tenant go too |

Decision records (the decision id, question ids and a hash of each question, the probabilities before and after calibration; never the state or the question text) are kept 30 days, then deleted automatically. Batches are kept 30 days from the upload, then deleted with their requests and results. Answers kept by `cache` (probabilities and option names, in the gateway's memory) are served for at most 24 hours.

## Batches

| Route | Body | Response |
|---|---|---|
| `POST /v1/batches` | `{"requests": [...]}`, or JSON Lines with `Content-Type: application/x-ndjson`; each request a `/v1/systemone` body with an optional `custom_id`; at most 5,000 | the job: `id`, `status`, `total` |
| `GET /v1/batches` | | `batches` |
| `GET /v1/batches/<id>` | | `status` (`queued`, `running`, `done`, `stopped: no credit left`, `stopped: account suspended`, `stopped: key revoked`, or `failed` with `error` when the job itself broke), `total`, `done`, `succeeded`, `failed`, `cost`, `expires` (when it is deleted), `removed` (requests taken out when their subject's memory was deleted) |
| `GET /v1/batches/<id>/results` | | JSON Lines: `custom_id`, `status`, `response` or `error`, in the order sent |

## Account

| Route | Response |
|---|---|
| `GET /v1/models` | `models` (name, engine, checkpoint, description, available, pinned: `runtime`, "engine" and the Jepela build id, and the `loaded` checkpoints; this tenant's fine-tuned `jepela-ft-*` models also carry `fine_tuned: true`), `default`, `aliases` |
| `GET /v1/usage` | `credited`, `spent`, `debited` (credit taken back by refunds and disputes), `balance`, `requests`, `rate_limit_per_minute`, `prices`, `totals`, `recent` (last 20 requests), `keys` |
| `POST /v1/signup` (no key) | `email`, `invite_code` when the gateway asks for one → `tenant`, `key` (shown once), `key_id`, `credit`, `currency`, `note`. Off unless the owner turns it on; by default 3 sign-ups per address a day, and the new key gets 60 requests per minute |

## No key needed

`GET /health` (whether the gateway, the memory service and the engine are up, the region, and the engine's code hash; nothing about tenants or subjects), `GET /v1/regions` (the regions, their API addresses and status; see Regions), `GET /` (a how-to), `GET /playground`, `GET /docs`, `GET /docs/<page>.md`, `GET /llms.txt`, `GET /robots.txt` (crawlers may read these pages; the `/v1` routes need a key).

## Errors

`{"error": {"type": "...", "message": "..."}}`

| Status | Type | When |
|---|---|---|
| 400 | `invalid_request_error` | a Content-Length that is not a number, a compressed body that does not decompress (or holds more than 64 gzip members), or JSON nested more than 100 levels deep |
| 401 | `authentication_error` | no key, unknown key, revoked key |
| 402 | `payment_required` | the tenant's balance is zero or below; never on a route that removes data: `POST /v1/memory/forget` (and its preview), `/v1/memory/delete`, `/v1/memory/rules/delete`, `/v1/memory/aliases/delete`, `/v1/golden/delete`, `/v1/decisions/delete` and `/v1/finetune/delete` |
| 403 | `signup_disabled`, `invite_required`, `account_suspended` | sign-up is off, or needs an invite code; or the account is suspended (a suspended account may still read its data on every `GET` route and call the routes that remove data, listed under 402) or erased |
| 404 | `not_found` | unknown route, page, batch, or a decision id of another tenant |
| 421 | `wrong_region` | the key (or the sign-up) belongs to another region; the message and the `Location` header give its API address |
| 409 | `conflict` | a limit: 5,000 golden cases for a tenant, 10,000 memory lines or 100 rules for a subject, nothing was stored; or a fine-tuning job that cannot start now (another of yours runs, or the engine is off) |
| 413 | `invalid_request_error` | body over the cap (a compressed body counts once decompressed) |
| 415 | `invalid_request_error` | not `application/json` (or JSON Lines for batches); or a `Content-Encoding` other than `gzip`, `x-gzip` or `zstd`, or more than one |
| 422 | `invalid_request_error` | the reason is in the message: bad question, unknown model (a Jev name such as `jev-latest` is told to set the model to `jepela`), bad subject id, a `derive` or `when` expression that is not allowed |
| 429 | `rate_limit_error` | over the account's requests per minute (all its keys together), or too many sign-ups; `Retry-After` in seconds |
| 500 | `server_error` | the gateway failed; the message names only the error type, and nothing was charged |
| 502 | `engine_error` | the engine or the memory service behind the gateway is down or answered an error; when the engine is switched off the message says `the engine is off`. Nothing was charged (a proxy in front of the gateway may answer 503 for the same) |

Retry 429 after `Retry-After`, and 502 or 503 with backoff. The SDKs do both, and never send a decision again once it may have arrived.
