# 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}}
```
