Documentation · ↑ All docs

FinLang API Reference

Status: v0.1 — SOL-041 MVP Applies to: FinLang v0.9.0+ (base install; before v0.9.0, the [api] extra — pip install "finlang[api]" remains valid as a no-op)

A thin REST surface over the FinLang CLI. Every endpoint dispatches to the published CLI entry points (finlang, finlang-discover, finlang-suggest) via subprocess. The API never imports engine internals; it inherits the CLI's underlying behaviour and exit codes (with one uniform mapping: exit 3 — a finding, whether a verify mismatch, a reconcile mismatch, or a behavioural change — maps to HTTP 200 across /process, /reconcile, and /impact, since a finding is a reported outcome, not a request error). The HTTP surface is curated, not auto-forwarding: each endpoint exposes specific Form parameters that map to CLI flags.


Install & run

pip install finlang
finlang-api               # binds 127.0.0.1:8000 by default

Or with uvicorn directly:

uvicorn finlang.api.main:app --host 0.0.0.0 --port 8000

Interactive Swagger UI at http://localhost:8000/docs.


Configuration

Env var Default Purpose
FINLANG_API_KEY unset (auth disabled) When set to a non-empty value, all non-health endpoints require X-API-Key: <key>. An empty string is treated as unset (auth disabled) — an unset host variable passed through compose must not arm a gate an empty header would satisfy. Key comparison is constant-time. (v0.8.1 behaviour change: previously an empty value armed auth.)
FINLANG_API_HOST 127.0.0.1 Bind host for finlang-api script
FINLANG_API_PORT 8000 Bind port
FINLANG_API_TIMEOUT 300 Subprocess timeout in seconds
FINLANG_API_MAX_UPLOAD 104857600 Max upload size in bytes (100 MiB)
FINLANG_API_LOG_LEVEL info Uvicorn log level

Endpoints

GET /health

Liveness check. No auth required.

{
  "status": "ok",
  "service": "finlang-api",
  "version": "0.9.1",
  "timestamp": 1747000000.0,
  "cli_resolved": true
}

GET /rulepacks

The bundled rulepacks, by the short name include_pack accepts. Served from the CLI's own PACK_MAP (aliases deduped, one entry per shipped file), so the listing cannot drift from what the engine resolves. No auth required.

{
  "packs": [
    {"name": "retail", "file": "01-vendors-retail.fin"},
    {"name": "transport", "file": "02-transport.fin"}
  ]
}

POST /rules/validate

Parse-check a rules file against the real engine — no second parser. The API runs the CLI against a bundled one-row CSV with audit off; rule parsing is the CLI's first step and exits 2 with FATAL before any data work, so a bad pack fails here for exactly the reason it would fail a real run.

Form fields (provide exactly one):

Field Type Meaning
rules file a .fin rules file
rules_text string rules source as text

Response — a parse failure is a result, not an HTTP error:

{"ok": true, "rule_count": 1, "rule_names": ["Test: Shell"], "error": null}
{"ok": false, "rule_count": null, "rule_names": [], "error": "FATAL: ..."}

rule_names is best-effort: the engine's preview line truncates past ten rules; rule_count is always authoritative. Auth required (as /process).

POST /process

Categorise a transactions CSV. Multipart form upload.

Form fields:

Field Type Required Notes
input_csv file yes Transactions CSV
rules file one of rules/include_pack .fin rules file
map_file file no Custom header mapping JSON
include_pack string one of rules/include_pack Comma-separated bundled packs (e.g. retail,transport)
audit_mode string no none | lite (default) | full
fastio bool no Use PyArrow IO
decimal string no Decimal separator (default .)
thousands string no Thousands separator
dayfirst bool no Parse dates as DD/MM
encoding string no Input encoding (default utf-8-sig)
output_encoding string no Output encoding (default utf-8)
strict_parse bool no Fail fast on malformed CSV
fail_threshold float no Max drop rate (default 0.01)
return_audit bool no Include audit log in response (default true)
verify bool no Run SHA-256 verify after categorisation
verify_full bool no Run full verify (overrides verify)
verify_html bool no Also return a self-contained HTML integrity report in verify_report_html (requires verify or verify_full)

Response 200:

{
  "output_csv": "date,counterparty,amount,...,category,flags\n2024-01-15,...",
  "audit": [{"row": 0, "rule": "GROCERIES: Tesco", "changes": {...}}],
  "verify_report": {"status": "PASS", "rows": 5, "mismatches": 0, ...},
  "verify_report_html": "<!DOCTYPE html>…",   // only when verify_html=true
  "stats": {
    "rows_in": 5,
    "rows_out": 5,
    "audit_entries": 2,
    "duration_seconds": 0.0612,
    "exit_code": 0
  },
  "stderr": ""
}

Error mapping:

Engine exit code HTTP Meaning
0 200 Success
1 500 Ops error (file not found, IO failure)
2 422 Validation/parse error
3 200 Verification mismatch — a finding, not an error: output + verify report in the body, see below

A verify mismatch is a finding (HTTP 200), not an error. When verify=true/verify_full=true and the engine exits 3, the API returns 200 with stats.exit_code = 3, the categorised output_csv preserved, and the verify report in the normal verify_report (parsed verify_report.json) and verify_report_html (when verify_html=true) fields. This is consistent with /reconcile and /impact, so a client running verify + reconcile in one flow can render the amber result and continue instead of dead-ending. (Changed in the SOL-112 review, 26 Aug 2026. Before that a verify mismatch mapped to HTTP 422 with the report carried in a structured detail object — consumers that branched on a 422 detail.error == "verify_failed" should now read stats.exit_code == 3 on a 200 instead.)

POST /discover

Find uncategorised counterparties as candidates for new rules.

Form fields:

Field Type Required Notes
input_csv file yes Categorised CSV from /process
min_count int no Minimum occurrences (default 3)
min_amount float no Minimum absolute amount filter
top_k int no Top-N by frequency
since_date string no YYYY-MM-DD cutoff
include_excluded bool no Include exclude=True rows
return_all bool no Also return the all-candidates table
encoding, decimal, thousands, dayfirst various no Locale flags

Response 200:

{
  "candidates_csv": "counterparty_fingerprint,example_counterparty_name,count,...",
  "all_candidates_csv": null,
  "stderr": ""
}

POST /suggest

Generate draft .fin rules from discovery candidates.

Form fields:

Field Type Required Notes
candidates_csv file yes Output from /discover
existing_rules file no Existing .fin for dedup
emit_match string no exact | fuzzy (default)
category string no Default category (default Review)
prefix string no Rule name prefix (default SUGGEST)
quote_style string no " (default) or '

Response 200:

{
  "rules_fin": "rule \"SUGGEST: VENDOR\" {\n  match:\n    - counterparty ~ \"*VENDOR*\"\n  ...",
  "stderr": ""
}

POST /reconcile

Reconcile FinLang's deterministic categorisation against an external system's output (typically an ML model). Returns a row-by-row mismatch summary, an optional self-contained HTML report, and the full audit trail.

Form fields:

Field Type Required Notes
input_csv file yes Transactions CSV
ml_output_csv file yes ML output CSV to reconcile against (positional alignment needs an identical row count; reconcile_key removes that requirement)
rules file one of rules/include_pack .fin rules file
map_file file no Custom header mapping JSON
include_pack string one of rules/include_pack Comma-separated bundled packs
reconcile_fields string no Comma-separated fields to compare. Default: category. Multi-field works (e.g. category,flags).
reconcile_html bool no Emit self-contained HTML report alongside JSON. Default: false.
reconcile_identity_fields string no Identity guard — comma-separated fields verified positionally before comparison (e.g. date,amount,counterparty). Misaligned rows → structural failure (HTTP 422). Mutually exclusive with reconcile_key.
reconcile_key string no Key-based alignment — comma-separated composite key (e.g. date,amount,counterparty). Matches by content; row counts may differ; unmatched rows surface in orphans_finlang_csv / orphans_ml_csv. Duplicate keys → HTTP 422. Mutually exclusive with reconcile_identity_fields.
reconcile_date_format string no Explicit strftime format for the ML output's dates (e.g. %d/%m/%Y) — the ML file is another system's export and can use a different convention. Omitted: inferred from the ML date column. Validated against the actual data (unparseable format → HTTP 422); requires reconcile_identity_fields or reconcile_key (inert in positional mode → HTTP 422). Decision recorded in summary.ml_date_convention.
audit_mode string no Always full for /reconcile. Other values rejected with HTTP 400.
fastio bool no Use PyArrow IO
decimal, thousands, dayfirst, encoding, output_encoding, strict_parse, fail_threshold various no Same as /process

Query params:

Param Type Default Notes
format string json Response shape selector. json returns the full ReconcileResponse (summary + mismatches CSV + HTML report + audit + stats). html returns the HTML report directly with Content-Type: text/html — convenient for browsers, Swagger UI, and curl > file.html. format=html requires reconcile_html=true (otherwise HTTP 400).

Response 200 (perfect match — exit 0):

{
  "summary": {
    "timestamp": "2026-05-15T10:42:13Z",
    "total_rows": 15,
    "matches": 15,
    "mismatches": 0,
    "match_rate_percent": 100.0,
    "perfect_match": true,
    "status": "PASS",
    "alignment_mode": "positional",
    "reconcile_fields": ["category"],
    "audit_entries_loaded": 15,
    "duration_seconds": 0.087
  },
  "mismatches_csv": "",
  "report_html": null,
  "audit": [{"row": 0, "rule": "...", "changes": {...}}, ...],
  "stats": {
    "duration_seconds": 0.0921,
    "exit_code": 0,
    "mismatches_found": false
  },
  "stderr": ""
}

Response 200 (mismatches found — exit 3):

{
  "summary": {
    "total_rows": 15,
    "matches": 13,
    "mismatches": 2,
    "match_rate_percent": 86.67,
    "perfect_match": false,
    "status": "REVIEW REQUIRED",
    ...
  },
  "mismatches_csv": "row_number,date,amount,counterparty,differing_fields,ml_category,finlang_category,finlang_rule_matched,finlang_audit_reason\n1,2024-01-15,-89.50,SHELL TRADING INTERNATIONAL,category,Utilities,Energy & Commodities,Energy: Shell,counterparty ~ \"*SHELL*\"\n4,2024-01-22,-250000.00,CAYMAN ISLANDS TRUST,category,Treasury Operations,Compliance: Offshore Jurisdictions,Compliance: Offshore Jurisdictions,counterparty ~ \"*CAYMAN*\"\n",
  "report_html": "<!doctype html>...",
  "audit": [...],
  "stats": {
    "duration_seconds": 0.103,
    "exit_code": 3,
    "mismatches_found": true
  },
  "stderr": ""
}

⚠️ Exit-code semantics: finding mismatches on /reconcile is the expected outcome, not an error — engine exit code 3 maps to HTTP 200 (mismatches surfaced in the body), the same as /process (verify mismatch) and /impact (behavioural change). Structural/client-data problems (exit 1 — e.g. row-count mismatch, identity-guard failure, duplicate keys) and validation errors (exit 2) both map to HTTP 422 here; note exit 1 differs by endpoint (/process treats it as an ops error → HTTP 500). The caller reads stats.mismatches_found and summary.mismatches to know what happened.

Error mapping (specific to /reconcile):

Engine exit code HTTP Meaning
0 200 Perfect match — every row agrees on every reconcile field
1 422 Structural / client-data — row-count mismatch, missing field, identity-guard failure, duplicate keys
2 422 Validation/parse error
3 200 Mismatches found — expected outcome. Body carries the detail.

Exit 1 is overloaded — mapped by its dominant meaning. Honestly: on these endpoints exit 1 is almost always an input problem the request can't be processed against (duplicate keys, row-count mismatch, missing field, identity-guard failure), so it returns 422. It can — rarely — be a genuine I/O failure; the engine doesn't separate the two with distinct exit codes, so the API maps by the dominant case rather than pretending exit 1 cleanly equals "alignment failure." So the caller can still discriminate, the 422 body is structured: error (machine enum — alignment_error for exit 1, validation_error for exit 2), exit_code, a message, and the full stderr (authoritative — names the specific cause). Branch on error + read stderr. A future engine exit-code split (see BACKLOG: API error taxonomy) would make this exact.

Key mode (reconcile_key): the response adds orphans_finlang_csv (FinLang rows with no ML match) and orphans_ml_csv (ML rows with no FinLang match); summary.alignment_mode becomes key:<fields> and summary.orphans_finlang_count / orphans_ml_count carry the counts. Orphans set exit 3 → HTTP 200 (review-needed). Identity-guard mode (reconcile_identity_fields) instead suppresses comparison and returns HTTP 422 when rows misalign.


POST /impact

Rule-change impact analysis: run the same input through a baseline (rules) and a candidate (impact_rules) rulepack, and report what the change does — rows re-categorised, indicative amount moved per transition, per-rule match deltas. Analysis run: writes no categorised output. (See impact.md for the feature explainer.)

Form fields:

Field Type Required Notes
input_csv file yes Transactions CSV
impact_rules file yes Candidate (proposed) .fin rulepack
rules file one of rules/include_pack Baseline (current) .fin rulepack
map_file file no Custom header mapping JSON
include_pack string one of rules/include_pack Comma-separated bundled packs (baseline)
impact_html bool no Emit self-contained HTML report. Default: false.
fastio, decimal, thousands, dayfirst, encoding, strict_parse, fail_threshold various no Same as /process

Query params:

Param Type Default Notes
format string json json returns the full ImpactResponse (summary + changes_csv + report_html + stats). html returns the HTML report directly with Content-Type: text/html. format=html requires impact_html=true (otherwise HTTP 400).

Response 200 (behavioural change — exit 3):

{
  "summary": {
    "schema": "impact/1",
    "rows_compared": 6,
    "behavioural_changed": 2,
    "attribution_changed": 2,
    "rows_stable": 2,
    "transitions": [{"old_category": "Energy", "new_category": "Utilities", "rows": 2, "indicative_amount": 215.50}],
    "rule_deltas": [...],
    "baseline_rules_sha256": "a3f1c2...",
    "candidate_rules_sha256": "b7d09a...",
    "status": "REVIEW REQUIRED",
    "amount_note": "Indicative totals — float arithmetic, not accounting-grade."
  },
  "changes_csv": "row_number,counterparty,amount,date,memo,old_category,new_category,...,change_class\n1,...,behavioural\n",
  "report_html": null,
  "stats": {"duration_seconds": 0.04, "exit_code": 3, "behavioural_changes_found": true},
  "stderr": ""
}

Error mapping (same as /reconcile): 0 → 200 (no behavioural change), 3 → 200 (behavioural change — expected, review-needed), 2 → 422 (validation), 1 → 422 (structural/parse).


Curl examples

# Health
curl -s http://localhost:8000/health | jq

# Categorise
curl -s -X POST http://localhost:8000/process \
  -F "input_csv=@transactions.csv" \
  -F "rules=@rules.fin" \
  -F "audit_mode=full" \
  -F "verify=true" \
  | jq .stats

# Discover candidates from a categorised CSV
curl -s -X POST http://localhost:8000/discover \
  -F "input_csv=@categorised.csv" \
  -F "min_count=5" \
  | jq -r .candidates_csv

# Generate draft rules
curl -s -X POST http://localhost:8000/suggest \
  -F "candidates_csv=@candidates.csv" \
  -F "emit_match=exact" \
  -F "category=Review" \
  | jq -r .rules_fin

# Reconcile against an ML output (with HTML report)
curl -s -X POST http://localhost:8000/reconcile \
  -F "input_csv=@transactions.csv" \
  -F "ml_output_csv=@ml_output.csv" \
  -F "rules=@rules.fin" \
  -F "reconcile_html=true" \
  | jq '{exit: .stats.exit_code, mismatches: .summary.mismatches, status: .summary.status}'

# Save the HTML report to disk for review (JSON-extract pattern)
curl -s -X POST http://localhost:8000/reconcile \
  -F "input_csv=@transactions.csv" \
  -F "ml_output_csv=@ml_output.csv" \
  -F "rules=@rules.fin" \
  -F "reconcile_html=true" \
  | jq -r .report_html > reconcile_report.html

# Cleaner: ?format=html returns the HTML directly, no JSON unwrapping needed
curl -s -X POST 'http://localhost:8000/reconcile?format=html' \
  -F "input_csv=@transactions.csv" \
  -F "ml_output_csv=@ml_output.csv" \
  -F "rules=@rules.fin" \
  -F "reconcile_html=true" \
  -o reconcile_report.html

With auth:

export FINLANG_API_KEY="your-secret"
finlang-api &
curl -H "X-API-Key: your-secret" http://localhost:8000/process ...

Deployment notes

The MVP wrapper is a single-process FastAPI app. For production:

  • Run behind a reverse proxy (nginx, Caddy) with TLS termination.
  • Use uvicorn --workers N or gunicorn with uvicorn.workers.UvicornWorker.
  • Set a non-zero FINLANG_API_KEY and rotate it.
  • Lower FINLANG_API_MAX_UPLOAD if exposed on the public internet.
  • Mount a fast tmpfs at /tmp (subprocess CSV staging happens there).

The wrapper is deliberately minimal. It is not a SaaS — it's a deployable service surface. Multi-tenant features, persistent storage, async job queues, and rate limiting are explicitly out of scope for this wrapper; those concerns belong to a hosted-service layer above it.


Determinism

Every endpoint preserves the engine's determinism contract: same input + same rules → same output, byte-for-byte. The subprocess boundary does not introduce non-determinism. Audit logs are reproducible.

This is the property that matters for buyers in regulated environments.

© FinLang Ltd

Source: FinLang-Ltd/finlang/docs/api_reference.md. Edit there, push, the website rebuilds.