View Markdown# API reference
Base URL: `http://<host>:8082`. Try the calls in the browser: the **[API console](/admin/console)** (step-by-step flows,
every endpoint, request / response / curl), or [`/docs`](/docs) (Swagger) and [`/redoc`](/redoc).
[TOC]
## Conventions
| | |
|---|---|
| App key | Header **`X-API-Key`** on **every app endpoint** (everything under `/v2/` except `/v2/health` and `/v2/admin/*`). The backend shares the key with the app teams. Missing → `401 missing_api_key`, wrong → `401 invalid_api_key`. It is checked before anything else runs |
| Device | Header `X-Device-Id`: a stable per-install id, 1-128 characters from `A-Z a-z 0-9 . _ : -`. **Required** on `POST /v2/voice/parse`, `POST /v2/nlu/parse`, `POST /v2/nlu/results`, `POST /v2/languages/requests` and `DELETE /v2/user-data`. It is optional elsewhere |
| Admin | Header `X-Admin-Key` on `/v2/admin/*`. If the server has no key set, those endpoints answer `503 admin_disabled` |
| Content type | JSON bodies (`application/json`), except audio uploads (`multipart/form-data`) |
| Languages | ISO codes (`gu`, `hi`, `mr`). Wherever a language is read, the English name (`Marathi`) or the native name (`मराठी`) also works |
| Amounts | Major units: `25.0` = ₹25. `0` means "no amount was said", so the app asks the user |
| Dates | `YYYY-MM-DD`. Send `reference_date` = the user's local today, so words like "kal" resolve correctly |
### Errors
Every error has the same shape. Some add extra fields.
```json
{"code": "unsupported_language", "message": "No speech model on this server for 'xx'."}
```
| HTTP | code | When |
|---|---|---|
| 400 | `missing_device_id` / `invalid_device_id` | `X-Device-Id` is absent or malformed |
| 400 | `unknown_language` | not an ISO code or name the server knows |
| 400 | `unsupported_language` | known language, but no speech model for it (or not for the forced `engine`) |
| 400 | `empty_audio` / `bad_audio` | the upload is empty or cannot be decoded |
| 401 | `missing_api_key` / `invalid_api_key` | `X-API-Key` is absent or wrong (every app endpoint) |
| 401 | `unauthorized` | wrong or missing `X-Admin-Key` |
| 404 | `model_file_not_found` / `not_found` | unknown model file or route |
| 413 | `audio_too_large` | over 10 MB (`LAXMI_MAX_AUDIO_BYTES`) |
| 422 | `invalid_request` | body or field validation failed; `details: [{field, message}]` |
| 422 | `no_speech` | voice/parse: nothing was recognised; includes `asr` |
| 422 | `no_expense_found` | no amount, item or merchant found; includes `event_id` (and `asr` for voice) |
| 503 | `model_unavailable` | the model is configured but could not be loaded |
| 503 | `api_key_not_configured` | the server has no app key set (`LAXMI_APP_API_KEYS`): app endpoints stay closed |
| 503 | `admin_disabled` | `LAXMI_ADMIN_API_KEY` is not set |
---
## GET /v2/health
Is the server up, and what is installed? It loads no model, so it is safe to poll. No key needed.
```json
{"status": "ok", "asr_onnx_languages": ["gu", "hi"], "asr_nemo_enabled": true,
"nlu_model_version": "1.2.0", "gemini_enabled": true}
```
---
## POST /v2/asr/transcribe
Audio → text.
**Request** `multipart/form-data`
| field | | |
|---|---|---|
| `audio` | required | WAV, M4A, MP3, FLAC, OGG … up to 10 MB. Any sample rate; the server converts to 16 kHz mono |
| `language` | required | `gu`, `hi`, `mr`, … or a name |
| `engine` | `auto` | `auto` = the ONNX pack if the language has one, otherwise the multilingual `.nemo`. Or force `onnx` / `nemo` |
| `romanize` | `false` | also return the text in Latin script |
| `consent_store_audio` | `false` | keep the audio for training (only with `X-Device-Id`; deleted after 90 days) |
**How it works**
1. The language is turned into a code, then the engine is chosen:
- `onnx`: FP16 pack for gu / hi, ~1 s
- `nemo`: 2.6 GB multilingual model covering 22 languages. The first call loads it (~45 s), later calls take ~1-2 s
2. The audio is decoded and resampled.
3. The model transcribes it.
4. A row is saved in `asr_events` (and the audio only if the user agreed).
**Response 200**
```json
{"transcript": "शंभर रुपये भाडे दिले", "romanized": "sambhara rupaye bhade dile", "language": "mr",
"engine": "nemo", "model_version": "nemo-indicconformer_stt_multi_hybrid_rnnt_600m", "confidence": 0.95,
"asr_event_id": "164c3ffee3474067b471e5dec40fea15"}
```
---
## POST /v2/voice/parse
Audio → text → expense in one call. `X-Device-Id` is required.
**Request** `multipart/form-data`: the fields of `/v2/asr/transcribe` (no `romanize`), plus:
| field | | |
|---|---|---|
| `currency` | `INR` | 3 letters |
| `reference_date` | server today (IST) | the user's local today, `YYYY-MM-DD` |
| `client_datetime` | | ISO datetime; used when `reference_date` is empty |
**How it works:** the speech step of `/v2/asr/transcribe`, then the [NLU pipeline](#post-v2nluparse). The expense's
`language` is the one the user spoke, not the one guessed from the script: Marathi and Hindi both use Devanagari.
**Response 200**: the `/v2/nlu/parse` response plus an `asr` block.
```json
{"amount": 300.0, "category": "Groceries", "merchant": null, "note": "શાકભાજી", "spent_at": "2026-09-20",
"language": "gu", "event_id": "9859b6a6ed8f4eb4b3fa9830f2e537c5", "transcript": "કાલે ત્રણસો ની શાકભાજી ખરીદી",
"currency": "INR", "confidence": 0.97, "needs_review": false, "source": "model", "model_version": "1.2.0",
"fallback_reason": null,
"asr": {"transcript": "કાલે ત્રણસો ની શાકભાજી ખરીદી", "romanized": null, "language": "gu", "engine": "onnx",
"model_version": "onnx-gu-fp16", "confidence": 0.98, "asr_event_id": "1f11…"}}
```
**422**: `no_speech` or `no_expense_found`. Both carry `asr`, so the app can still show what it heard.
---
## POST /v2/nlu/parse
Text → expense. `X-Device-Id` is required (it drives the per-device Gemini limit).
**Request**
```json
{"transcript": "kal swiggy se 350 ka khana mangaya", "reference_date": "2026-09-21", "currency": "INR"}
```
`language` (optional): send it when the app knows it, e.g. from the language picker. Marathi and Hindi share a
script, so without it Marathi text is treated as Hindi.
**How it works**
1. **Cache**: the same sentence on the same day returns the earlier answer.
2. **Language check**: only gu / hi / en go to the ONNX NLU model (`LAXMI_NLU_LANGUAGES`). Other languages skip to
Gemini. The language is the one sent (voice/parse always knows it), else the one guessed from the script.
3. **Model**: the ONNX model file (`models/nlu/<version>/laxmi_nlu.onnx`) returns JSON.
4. **Safety checks** on that JSON:
- valid JSON with a known category
- the amount matches what the rules read from the sentence
- the date matches the rules ("kal" = yesterday)
- the amount is actually in the sentence
- the model is confident
- a `note` or `merchant` that is not in the sentence is invented, so it is removed and the result is marked for
review
5. **All checks pass** → `source: "model"`.
6. **A check fails** → Gemini is called, if the budget allows (monthly total and daily per device), and returns
`source: "gemini"`.
7. **Gemini unavailable or no help** → the model's answer is returned with `needs_review: true`, or else the
deterministic rules answer with `source: "rules"` and `needs_review: true`.
8. **Nothing found** → `422 no_expense_found`.
Every call is stored in `nlu_events` as training data.
**Response 200**
```json
{"amount": 350.0, "category": "Food & Drink", "merchant": "Swiggy", "note": "khana", "spent_at": "2026-09-20",
"language": "hi", "event_id": "ed00fb877d514d19a3e4ae7c395afa1f", "transcript": "kal swiggy se 350 ka khana mangaya",
"currency": "INR", "confidence": 1.0, "needs_review": false, "source": "model", "model_version": "1.2.0",
"fallback_reason": null}
```
| field | meaning |
|---|---|
| `source` | `model` / `gemini` / `rules`: who produced the answer |
| `needs_review` | `true` means show the confirm screen with the fields highlighted |
| `fallback_reason` | why the model answer was not used as is, e.g. `rules_disagree_amount`, `low_confidence`, `invented_note`, `unsupported_language; gemini_disabled` |
| `event_id` | send it back in `POST /v2/nlu/results` when the user saves |
Categories: `Food & Drink`, `Groceries`, `Transport`, `Fuel`, `Rent`, `Bills`, `Health`, `Shopping`,
`Entertainment`, `Education`, `Travel`, `Personal Care`, `Gifts/Donations`, `Investments`, `Misc`.
---
## POST /v2/nlu/results
What the user finally saved: the training signal. It covers both online and offline parses in one call.
`X-Device-Id` is required. Up to 200 entries per call. It never calls Gemini.
**Request**
```json
{"entries": [
{"id": "app-res-0001", "event_id": "ed00fb877d514d19a3e4ae7c395afa1f",
"final": {"amount": 350, "category": "Food & Drink", "merchant": "Swiggy", "note": "khana",
"spent_at": "2026-09-20", "language": "hi"},
"edited_fields": [], "confirm_time_ms": 2100},
{"id": "app-res-0002", "transcript": "ચા 20", "reference_date": "2026-09-21",
"created_at": "2026-09-21T09:00:00+05:30", "model_version": "1.2.0", "model_confidence": 0.93,
"final": {"amount": 20, "category": "Food & Drink", "note": "ચા", "spent_at": "2026-09-21", "language": "gu"}}
]}
```
| field | |
|---|---|
| `id` | made by the app, 8-64 chars, unique per saved expense. **Idempotency key** |
| `event_id` | online: from `/v2/nlu/parse` or `/v2/voice/parse`. Omit it for offline entries |
| `final` | the saved expense |
| `edited_fields`, `edit_reason`, `transcript_edited`, `confirm_time_ms` | optional. `edit_reason`: `model_error` / `user_changed` / `asr_error` |
| `transcript`, `reference_date`, `created_at` | **required for offline entries** |
| `model_json`, `model_version`, `model_confidence`, `asr_model_version` | optional, offline: what the on-device models produced |
**How it works:** for each entry:
1. An `id` seen before → `duplicates`.
2. Online → attached to its event, which must belong to the same device.
3. Offline → stored as a new `offline` event.
4. Bad input → `rejected` with a reason.
Re-sending the whole batch after a timeout is always safe.
**Response 200**
```json
{"saved": ["app-res-0001", "app-res-0002"], "duplicates": [], "rejected": []}
```
Rejection reasons: `event_not_found`, `invalid_date`, `offline_entry_needs_transcript_reference_date_created_at`.
---
## GET /v2/models
Everything the app can download for offline use: one list for the speech packs and the NLU model.
| query | |
|---|---|
| `type` | `asr` or `nlu` |
| `lang` | only models for this language |
| `installed` | what the app has, e.g. `asr-gu@20260911.115542,nlu@1.1.0`. Sets `update_available` per model |
| `max_nlu_schema` | the newest NLU output `schema_version` this app build understands; newer models are skipped |
**Response 200** (files shortened)
```json
{"models": [
{"id": "asr-gu", "type": "asr", "languages": ["gu"], "version": "20260911.115542", "format": "onnx",
"precision": "fp16", "size_bytes": 261122803, "created_at": "2026-09-11T11:55:42+00:00",
"files": [{"name": "encoder.onnx", "url": "http://host:8082/v2/models/asr-gu/files/encoder.onnx",
"size_bytes": 251271775, "sha256": "abe1837e…"}, "…"],
"installed_version": "20260911.115542", "update_available": false},
{"id": "nlu", "type": "nlu", "languages": ["gu", "hi", "en"], "version": "1.2.0", "schema_version": 1,
"review_threshold": 0.8, "size_bytes": 58131424,
"files": [{"name": "laxmi_nlu.onnx", "url": "http://host:8082/v2/models/nlu/files/laxmi_nlu.onnx?version=1.2.0",
"size_bytes": 58034536, "sha256": "25fc717f…"},
{"name": "manifest.json", "…": "…"}, {"name": "vectors.jsonl", "…": "…"}],
"installed_version": "1.1.0", "update_available": true}
]}
```
| model | files |
|---|---|
| `asr-<lang>` | `preprocessor.onnx`, `encoder.onnx`, `decoder.onnx`, `joiner.onnx`, `tokens.txt`, `manifest.json` (the manifest spells out the decoding pipeline) |
| `nlu` | `laxmi_nlu.onnx` (self-contained: tokenizer + generation + JSON), `manifest.json`, `vectors.jsonl` (test sentences with this file's exact output: the app must reproduce them) |
Download links use the host the app called, or `LAXMI_PUBLIC_BASE_URL` behind a proxy.
---
## GET /v2/models/{id}/files/{name}
Downloads one file. `HEAD` gives the size; `Range: bytes=N-` resumes a broken download (`206`). `?version=`
selects an older NLU version (for rollback). Only the file names listed above are served.
---
## GET /v2/languages
Every language the app can show. With `X-Device-Id`, it also marks what this device has requested.
```json
{"supported": ["gu", "hi"],
"languages": [
{"code": "gu", "name": "Gujarati", "native_name": "ગુજરાતી", "supported": true, "offline_asr": true,
"server_asr": true, "requested_by_device": false},
{"code": "mr", "name": "Marathi", "native_name": "मराठी", "supported": false, "offline_asr": false,
"server_asr": true, "requested_by_device": true},
"…"]}
```
| field | meaning |
|---|---|
| `supported` | offered in the app now (`LAXMI_SUPPORTED_LANGUAGES`) |
| `offline_asr` | a downloadable speech pack exists |
| `server_asr` | the server can transcribe it (ONNX pack or the `.nemo` model) |
| `requested_by_device` | this device asked for it (`null` without `X-Device-Id`) |
The list covers the 22 scheduled Indian languages plus English.
---
## POST /v2/languages/requests
A user asks for a language. `X-Device-Id` is required.
```json
{"language": "Marathi", "platform": "android", "app_version": "1.4.0", "note": "optional text from the user"}
```
**How it works:**
1. The name is turned into a code (`Marathi` / `मराठी` / `MR` → `mr`). Unknown → `400 unknown_language`.
2. Already supported → `supported: true`, and nothing is stored.
3. Otherwise there is one row per (device, language) in `language_requests`. Asking again only raises that row's
`request_count`, so demand is measured in **unique devices**.
**Response 200**
```json
{"language": "mr", "name": "Marathi", "native_name": "मराठी", "supported": false, "already_requested": false,
"device_request_count": 1, "total_devices": 2}
```
---
## GET /v2/admin/language-requests
`X-Admin-Key` (not the app key). Demand per language, most unique devices first: what to add next.
| query | |
|---|---|
| `language` | one language only |
| `min_devices` | hide languages below this many devices (default 1) |
| `limit` | default 50 |
```json
{"total_devices": 2, "languages": [{"language": "mr", "name": "Marathi", "devices": 2, "requests": 2,
"first_requested_at": "2026-09-21T10:58:14+00:00", "last_requested_at": "2026-09-21T10:58:14+00:00"}]}
```
---
## GET /v2/admin/training-data
`X-Admin-Key`. NLU events for retraining: the transcript, the model / Gemini output, and what the user saved.
| query | |
|---|---|
| `with_result_only` | default `true`: only events with a saved result |
| `language` | filter by language |
| `since` | ISO datetime |
| `limit` | default 1000, max 10000 |
```json
{"count": 2, "items": [{"id": "…", "device_id": "…", "channel": "online", "transcript": "…",
"model_json": {"…": "…"}, "gemini_json": null, "response_json": {"…": "…"}, "final_json": {"…": "…"},
"edited_fields": [], "edit_reason": null, "source": "model", "fallback_reason": null, "…": "…"}]}
```
---
## GET /v2/admin/reviews
`X-Admin-Key`. Results the user **edited**: the review queue behind the page `/admin/reviews`. An edited result is
used for training only after it is approved.
| query | |
|---|---|
| `status` | `pending` / `approved` / `rejected` / `skipped` (empty: all) |
| `language`, `channel` (`online` / `offline`), `source` (`model` / `gemini` / `rules`), `device_id` | filters |
| `field` | only results where this field was edited (`amount`, `category`, `merchant`, `note`, `spent_at`, `language`, `transcript`) |
| `suggestion` | what the automatic rules think: `gold` / `silver` / `skip` |
| `q` | search in the transcript and notes |
| `order` | `newest` (default) / `oldest` |
| `limit`, `offset` | paging (default 50) |
```json
{"counts": {"pending": 2, "approved": 1, "rejected": 0, "skipped": 0}, "total": 3,
"items": [{"event_id": "…", "status": "pending", "transcript": "પેટ્રોલ ના ત્રણ સો રૂપિયા", "transcript_edited": null,
"language": "gu", "channel": "online", "source": "model", "suggestion": "silver", "device_id": "…",
"changes": [{"field": "amount", "model": 300, "user": 500}], "updated_at": "…", "…": "…"}],
"facets": {"language": {"gu": 2}, "field": {"amount": 2}, "suggestion": {"silver": 2}, "…": {}}}
```
## GET /v2/admin/reviews/{event_id}
`X-Admin-Key`. One edited result:
- per field: the model's value, the user's, the approved one, whether it was edited, and the rules' check
- the full change history (`old` → `new`, by `user` or `admin`, with the time)
- the rules' suggestion
- the training label it has now (if any)
## POST /v2/admin/reviews/{event_id}
`X-Admin-Key`. A decision.
```json
{"action": "approve", "reviewer": "rutvik", "note": "transcript says 300",
"label": {"amount": 300, "category": "Fuel", "merchant": null, "note": "પેટ્રોલ", "spent_at": "2026-09-21", "language": "gu"},
"transcript": null}
```
| `action` | effect |
|---|---|
| `approve` | `label` (default: what the user saved) and `transcript` (default: the user's) become a **gold** training label. Every value that differs from the user's is added to the history as an admin edit |
| `reject` | never used for training |
| `skip` | not used for training (unsure / test data); can be reviewed again |
| `reset` | back to `pending`; the training label is removed |
The response is the updated detail. `404 review_not_found` means the id is not a user-edited result; an invalid
`label` is `422`.
## POST /v2/admin/reviews/bulk
`X-Admin-Key`. `{"event_ids": [...], "action": "approve" | "reject" | "skip" | "reset", "reviewer", "note"}`, up to
200 ids. `approve` uses what each user saved, unchanged. Returns `{"updated": [...], "not_found": [...]}`.
## NLU model versions (admin)
`X-Admin-Key`. Behind the page **`/admin/models`**. The served version is `models/nlu/latest.json`, unless
`LAXMI_NLU_MODEL_VERSION` pins it (then switching returns `409 model_pinned`).
| Call | What it does |
|---|---|
| `GET /v2/admin/nlu-models` | `served`, `pinned`, `can_switch`, `rollback_to`, `versions` (manifest, metrics, `served`, `offered_to_apps`, `complete`), `history` |
| `POST /v2/admin/nlu-models/{version}/check` | files, sha256, loads, valid JSON on sample phrases → `{ok, checks[], samples[]}`. Changes nothing |
| `POST /v2/admin/nlu-models/{version}/activate` | `{"reviewer", "note"}`. Checks, then points `latest.json` at it. `409`: `model_pinned`, `already_served`, `model_check_failed` (with `checks`); `404 model_version_not_found` |
| `POST /v2/admin/nlu-models/rollback` | `{"reviewer", "note"}`. Back to the previously served version. `409 no_rollback_target` if none |
| `POST /v2/admin/nlu-models/try` | `{"transcript", "reference_date"?, "versions"?}` → raw output of each version side by side. Nothing saved |
The server uses the new version on the next request (no restart); apps get `update_available: true` from
`GET /v2/models` and download it (also after a rollback).
## Speech models (admin, read only)
`X-Admin-Key`. The **Speech models** tab of `/admin/models`. Nothing is loaded or changed.
| Call | What it does |
|---|---|
| `GET /v2/admin/asr-models` | `packs` (per language: `enabled`, `complete`, `in_use`, `loaded`, `version`, `created_at`, `precision`, size, `files`, `source_model`), `nemo` (`path`, `bytes`, `file_present`, `toolkit_installed`, `in_use`, `loaded`, `languages`), `routing` (per language: `auto_engine`, `onnx`, `nemo`, `offline_download`), `sources` (single-language `.nemo` export files) |
| `POST /v2/admin/asr-models/{lang}/check` | every pack file present, size and sha256 match `manifest.json` → `{ok, checks[]}`. `404 asr_pack_not_found` |
## Word lists (admin, read only)
`X-Admin-Key`. Behind the page **`/admin/lexicon`**.
| Call | What it does |
|---|---|
| `GET /v2/admin/lexicon` | per category: `items` (per language), `loanwords` (English words used in gu / hi speech, in 3 forms), `merchants`, `rule_keywords`, plus `templates`, `dates`, `currency` and totals. `training_lexicon.found` is false when `nlu_model/` is not on the server (only rule keywords then) |
| `POST /v2/admin/lexicon/try` | `{"text": "..."}` → what the **rules** make of it: `category`, the `keyword` that matched, and the `amount` they read. The model is tested with `/v2/admin/nlu-models/try` |
The lists live in `nlu_model/lexicon/` (`LAXMI_NLU_LEXICON_DIR` to point elsewhere) and
`server/data/indic_categories.json`; they are edited there, never through the API.
## DELETE /v2/user-data
Right to erasure. `X-Device-Id` is required. Deletes the device's stored audio files, speech and NLU events,
training labels, edit history, reviews and language requests.
```json
{"device_id": "abc", "deleted": {"audio_files": 0, "asr_events": 14, "nlu_events": 9, "language_requests": 1}}
```