# Finology Software Public API: endpoint reference

Base URL: `https://api.finology.tech`. Every `/v1` route needs an API key in the
`X-Api-Key` header (`Authorization: Bearer <key>` is also accepted). Get a key:
https://finology.tech/auth.md. The same key works on the MCP server at
`https://mcp.finology.tech/mcp`, whose four tools map one to one onto the four
calculation routes below.

The surface is calculation only. Every loan and scenario number travels in the
request; nothing about a person is read, stored or returned. Money is in US
dollars; rates and growth are fractions (0.0654 means 6.54%).

Validation is strict on purpose. A missing or unrecognised required field is a
`400` naming the field, never a default and a success status. Every calculation
answer is written to the answer-of-record ledger before it is returned; if the
row cannot be written the answer is withheld with a `503`.

Use of the Service is governed by the API and MCP Terms of Service at
https://finology.tech/api-terms/. Sending a request is acceptance.

## Routes

| Method | Path | What it does |
|---|---|---|
| POST | `/v1/projection` | One loan book under one plan |
| POST | `/v1/comparison` | 2 to 10 arms side by side, engine picks the cheapest |
| POST | `/v1/plan-eligibility` | Which plans each loan can elect |
| GET | `/v1/plan-types` | The exact plan strings the routes accept |
| GET | `/v1/me` | Tenant, tier and key prefix behind the key (no secrets) |
| POST | `/v1/keys/sandbox` | Mint a sandbox key (no auth, `{"email": "..."}`) |
| POST | `/v1/keys/claim` | Collect a paid production key with a sandbox key (no auth) |
| GET | `/health` | Liveness and deployed commit |

## Shared shapes

### Loan

One federal loan. Used by every calculation route.

| Field | Type | Required | Notes |
|---|---|---|---|
| `loanType` | string | yes | One of the loan type strings below; anything else is a 400. No default: the type decides plan eligibility (a Parent PLUS loan cannot elect RAP). |
| `principalBalance` | number | yes | 0 to 100,000,000 |
| `interestBalance` | number | no | Accrued interest, default 0 |
| `interestRate` | number | yes | Fraction, 0 to 1. `0.068` is 6.8%; `6.8` is rejected. |
| `disbursedAmount` | number | no | Original disbursement. 0 falls back to the principal balance. |
| `idrMonthsInRepayment` | integer | no | Months already counted toward IDR or RAP forgiveness, 0 to 600 |
| `pslfCount` | integer | no | Qualifying PSLF payments so far, 0 to 120 |

Loan type strings, as NSLDS spells them. Matching is case-insensitive and
trims whitespace; anything not on this list is a `400` naming the loan and the
accepted set.

```
DIRECT CONSOLIDATED SUBSIDIZED
DIRECT CONSOLIDATED SUBSIDIZED (SULA ELIGIBLE)
DIRECT CONSOLIDATED UNSUBSIDIZED
DIRECT PARENT PLUS
DIRECT PARENT PLUS CONSOLIDATED
DIRECT PLUS CONSOLIDATED
DIRECT PLUS GRADUATE
DIRECT PLUS PARENT
DIRECT STAFFORD SUBSIDIZED
DIRECT STAFFORD SUBSIDIZED (SULA ELIGIBLE)
DIRECT STAFFORD UNSUBSIDIZED
DIRECT UNSUBSIDIZED (TEACH)
FEDERAL PERKINS
FEDERALLY INSURED (FISL)
FFEL CONSOLIDATED
FFEL PARENT PLUS
FFEL PARENT PLUS CONSOLIDATED
FFEL PLUS GRADUATE
FFEL PLUS PARENT
FFEL REFINANCED
FFEL STAFFORD NON-SUBSIDIZED
FFEL STAFFORD SUBSIDIZED
FFEL STAFFORD UNSUBSIDIZED
FFEL SUPPLEMENTAL LOAN (SLS)
HEALTH PROFESSIONS STUDENT LOAN (HPSL)
INCOME CONTINGENT (ICL)
NATIONAL DEFENSE LOAN (PERKINS)
NATIONAL DIRECT STUDENT LOAN (PERKINS)
PERKINS EXPANDED LENDING
```

### Scenario

The household and the plan. Used by `/v1/projection` and by each arm of `/v1/comparison`.

| Field | Type | Required | Notes |
|---|---|---|---|
| `planType` | string | yes | One of the strings from `GET /v1/plan-types` |
| `adjustedGrossIncome` | number | yes | Borrower AGI per year. Send `0` for no income; omitting it is a 400. |
| `spouseAdjustedGrossIncome` | number | no | Counted only when filing jointly |
| `filingStatus` | string | yes | `Single`, `MarriedFilingJointly`, `MarriedFilingSeparately`, `HeadOfHousehold`. Case-insensitive; `MFJ`, `MFS` and `HOH` are accepted aliases. Anything else is a 400. |
| `dependents` | integer | yes | 0 to 30. Send `0` explicitly. |
| `state` | string | yes | Two-letter US state code. Drives the state tax on forgiven balances (AR, IN, MS, NC, WI). |
| `workingTowardsPslf` | boolean | yes | Send `false` explicitly. |
| `annualIncomeGrowth` | number | no | Fraction per year, 0 to 0.5, default 0 |
| `label` | string | no | Up to 80 characters, echoed back. Useful in comparison arms. |

### Plan types

`GET /v1/plan-types` returns:

```
["STANDARD","EXTENDED","GRADUATED","INCOME BASED","INCOME BASED (OLD)",
 "INCOME CONTINGENT","PAY AS YOU EARN","REVISED PAY AS YOU EARN","SAVE",
 "REPAYMENT ASSISTANCE"]
```

Eligibility is decided per loan and per plan. Ask `/v1/plan-eligibility` before
projecting a plan a loan may not be able to elect.

### Provenance

Every calculation response carries a `provenance` object:

| Field | Meaning |
|---|---|
| `computedBy` | Always the Finology engine |
| `engine` | The engine component that produced the numbers |
| `parityVerified` | `true` when the numbers come from the parity-verified calculator. `/v1/plan-eligibility` returns `false` here because eligibility is rule logic, not a dollar calculation. |
| `computedAtUtc` | When the answer was computed |
| `ruleVersion` | The rule ledger version, for example `rl1-0f808d5944824f75`. The same value is written to the answer-of-record row. |
| `citations` | The primary sources behind the constants used. A projection that priced a tax on a forgiven balance also cites the federal bracket table for the filing status and, in a state that taxes forgiven student debt, that state's rate authority: `id`, `label`, `authority`, `url`, `effectiveDate`, `lastVerified` |
| `knownOmissions` | Rules the engine knowingly does not apply to this answer, each with `id`, `label`, `detail`, `authority`, `url`, `effectiveDate`, `expiresDate`, `lastVerified`. Present on projection and comparison answers. Today this discloses the Department of Education auto-pay interest reduction (July 1, 2026 through June 30, 2028), which projections do not model: interest and totals are overstated for borrowers on auto pay. |
| `answerId` | The public handle of this answer's record. Present on every keyed REST and MCP answer. |
| `verifyUrl` | `https://api.finology.tech/v1/verify/{answerId}`. Anyone can open it, no key required, to confirm the answer was recorded: when, which endpoint, which rule ledger version, which inputs hash. It never returns the inputs or identifies the caller. |

## GET /v1/verify/{answerId}

Anonymous. Confirms that a keyed answer was recorded before it was served. Rate limited per IP.

Response:

```json
{
  "answerId": "6f1c2e0a-2b7d-4c1e-9d3a-0b8e5f4a1c22",
  "recordedAtUtc": "2026-09-09T02:14:07.4412Z",
  "source": "PublicApi",
  "endpoint": "POST /v1/projection",
  "eventType": "calc.projection.run",
  "ruleVersion": "rl1-f9923cade1e9ea34",
  "inputsHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "recorded": true
}
```

A browser that asks for `text/html` gets the same record as a plain page. An unknown id returns a 404 problem.

To check the number itself, re-submit the same inputs to the same endpoint: the new answer carries the same `ruleVersion` while the rules are unchanged, and its record's `inputsHash` matches this one when the inputs are the same. A different `ruleVersion` means the ledger moved; the new answer's `citations` say which source.

## Availability and monitoring

- `GET /health` and `GET /v1/health` on api.finology.tech are anonymous and report the commit the host was built from, so you can see which build answered.
- Synthetic probes run every thirty minutes against the app, the identity server, the internal API and the public calculator, and check that the free RAP calculator returns the expected number, not only a 200.
- The rule ledger's primary sources are probed weekly; a moved source is a finding, and `GET /v1/rules/version` changes when a constant does.
- Every calculator change runs the parity harness against the oracle before it can merge, and the suite of guard tests on every pull request.
- The public API runs as a single instance in one region. Support is by email at support@finology.tech.

## GET /v1/rules

Anonymous, cacheable. The rules ledger every answer cites: each constant with its value in named fields, its definition, its primary source (authority, URL, note, and how often it republishes), its effective and last-verified dates, and whether a parity scenario locks it; the rules the engine knowingly does not apply; and the ledger version.

`GET /v1/rules/version` returns `{"version": "rl1-..."}`. Poll it. When it changes, a constant moved, and `/v1/rules` says which. The `ETag` on both is the version, so a conditional request answers 304.

Response (abridged):

```json
{
  "version": "rl1-f9923cade1e9ea34",
  "generatedAtUtc": "2026-09-09T06:10:00Z",
  "constants": [
    {
      "id": "fpl.2026.contiguous",
      "label": "2026 Federal Poverty Line (48 states + DC)",
      "plan": "FPL",
      "unit": "USD/year",
      "value": { "baseHousehold1": 15960, "perAdditionalPerson": 5680 },
      "definition": "...",
      "source": { "authority": "HHS Annual Update of the Poverty Guidelines, 91 FR (2026-01-15), FR Doc. 2026-00755", "url": "https://www.federalregister.gov/...", "note": "...", "cadence": "republished annually around January 1; ..." },
      "effectiveDate": "2026-01-01",
      "lastVerified": "2026-06-13",
      "parityLocked": false
    }
  ],
  "knownOmissions": [ { "id": "omission.autopay_interest_reduction", "...": "..." } ],
  "howToUse": "..."
}
```

## GET /v1/records

Keyed. Every answer recorded under your account, newest first. Query parameters: `from` and `to` (ISO 8601, half-open), `cursor` (from a previous page's `nextCursor`), `limit` (1 to 1000, default 100).

```json
{
  "records": [
    {
      "answerId": "5c8450e7-6a1e-4fcc-bf05-7dc7a45eb8ac",
      "recordedAtUtc": "2026-09-09T04:52:06.0954401+00:00",
      "source": "PublicApi",
      "endpoint": "GET /v1/reference#single_federal",
      "eventType": "calc.comparison.run",
      "ruleVersion": "rl1-f9923cade1e9ea34",
      "inputsHash": "ce96...",
      "keyPrefix": "fk_live_AB12CD",
      "verifyUrl": "https://api.finology.tech/v1/verify/5c8450e7-6a1e-4fcc-bf05-7dc7a45eb8ac"
    }
  ],
  "nextCursor": 184233
}
```

`nextCursor` is null on the last page. Rows never carry the inputs; the hash lets you match a row to a request you kept.

## GET /v1/reference

The parity oracle, served live. Returns four canonical federal-loan scenarios (single borrower; joint return with two incomes; PSLF at 60 of 120 payments; a borrower with prior qualifying IDR months), each computed on the current engine under RAP, IBR and Standard, recorded as an answer of record, and returned with the exact `request` to replay it through `POST /v1/comparison` and a `verifyUrl` for its record.

Response (abridged):

```json
{
  "ruleVersion": "rl1-f9923cade1e9ea34",
  "computedAtUtc": "2026-09-09T02:40:11.2Z",
  "howToUse": "...",
  "scenarios": [
    {
      "id": "single_federal",
      "title": "Single borrower, two federal loans, no dependents",
      "description": "AGI $72,000, single filer, Idaho. $48,200 at 6.54% and $11,900 at 4.45% ...",
      "oracleScenario": "single_federal_save_to_rap",
      "request": { "arms": [ { "scenario": { "planType": "REPAYMENT ASSISTANCE", "...": "..." }, "loans": [ "..." ] } ] },
      "answer": { "projections": [ "..." ], "lowestCostLabel": "...", "provenance": { "answerId": "...", "verifyUrl": "...", "citations": [ "..." ], "knownOmissions": [ "..." ] } }
    }
  ]
}
```

Use it to check the engine before integrating: replay any `request` unchanged and compare. A readable version of the four scenarios with their current numbers and records is at https://finology.tech/reference.md. The scenario descriptions state the conventions the numbers rest on, including that Standard amortizes over the months left in the 10-year term when prior months are supplied.

## POST /v1/projection

Request:

```json
{
  "scenario": {
    "planType": "REPAYMENT ASSISTANCE",
    "adjustedGrossIncome": 75000,
    "filingStatus": "Single",
    "dependents": 0,
    "state": "NC",
    "workingTowardsPslf": false
  },
  "loans": [
    { "loanType": "DIRECT STAFFORD UNSUBSIDIZED",
      "principalBalance": 92850, "interestBalance": 0, "interestRate": 0.068 }
  ]
}
```

`loans` holds 1 to 100 loans.

Response (`200`):

```json
{
  "label": null,
  "planType": "REPAYMENT ASSISTANCE",
  "year1MonthlyPayment": 438,
  "payoffOrForgivenessDate": "2056-09-01T00:00:00+00:00",
  "monthsToFinish": 360,
  "totalOutOfPocket": 157500.0,
  "amountForgiven": 74462.5,
  "forgivenessTaxesOwed": 20228.05375,
  "forgivenessType": 1,
  "weightedAverageInterestRate": 0.068,
  "provenance": { "...": "see Provenance" }
}
```

| Field | Meaning |
|---|---|
| `year1MonthlyPayment` | Payment in the first month of repayment, whole dollars |
| `payoffOrForgivenessDate` | When the loans finish, by payoff or forgiveness. `null` if the projection never terminates in the window. |
| `monthsToFinish` | Months from repayment start to that date |
| `totalOutOfPocket` | Everything the borrower pays over the projection |
| `amountForgiven` | Balance forgiven at the forgiveness event, 0 if the loans amortise to zero |
| `forgivenessTaxesOwed` | Estimated federal plus state tax on the forgiven balance at today's brackets. PSLF forgiveness is tax free. |
| `forgivenessType` | Which forgiveness program the projection reaches, if any |
| `weightedAverageInterestRate` | Across the loan book, as a fraction |

## POST /v1/comparison

Request: `arms`, an array of 2 to 10 objects, each `{ "scenario": Scenario, "loans": [Loan] }`.
The usual use is the same loans under different plans; give each scenario a `label`.

Response (`200`):

```json
{
  "projections": [ { "label": "RAP", "...": "one projection per arm" } ],
  "lowestCostLabel": "RAP",
  "provenance": { "...": "see Provenance" }
}
```

`lowestCostLabel` is decided by the engine on `totalOutOfPocket`. One
answer-of-record row is written per comparison, covering every arm.

## POST /v1/plan-eligibility

Request: `{ "loans": [Loan] }`, 1 to 100 loans.

Response (`200`):

```json
{
  "loans": [
    { "loanType": "DIRECT STAFFORD UNSUBSIDIZED", "balance": 92850,
      "eligiblePlanTypes": ["STANDARD", "EXTENDED", "GRADUATED", "INCOME BASED", "REPAYMENT ASSISTANCE"] }
  ],
  "provenance": { "...": "parityVerified is false here: eligibility is rule logic, not a dollar calculation" }
}
```

## GET /v1/me

```json
{ "tenant": "475", "tier": "Sandbox", "keyPrefix": "fk_test_Yb3yex" }
```

## POST /v1/keys/claim

The agent collects the production key itself, so nobody has to read the key
email. Sequence: the agent mints a sandbox key for the operator's email; the
sandbox response's `upgradeUrl` is the Payment Link with that email prefilled;
the operator pays; the agent calls this endpoint with the sandbox key.

Request (no auth, IP rate limited):

```json
{ "sandboxKey": "fk_test_..." }
```

The sandbox key may be expired or rotated; possession of the secret is the proof.

Responses:

| Status | Meaning |
|---|---|
| `200` | Production key issued, shown once: `apiKey`, `keyPrefix`, `tier`, `monthlyCallLimit`, `revokedKeyPrefix`, `notice`. The key that was emailed is revoked; only one is ever live. |
| `404` | No subscription has been paid with the sandbox key's email. Body carries `email` and `upgradeUrl` (prefilled). Pay, then call again. |
| `409` | The emailed key has already been used, so it was not rotated. Use the emailed key, or cancel and resubscribe. |
| `400` | Not a sandbox key. |

A claim is a rotation: raw keys are never stored, so a fresh key is minted on
the same subscription. Claiming again before the claimed key is used rotates
again; once it has been used it is protected the same way.

## Errors

| Status | When | Body |
|---|---|---|
| `400` | A required field is missing, a value is out of range, or an enum string is unrecognised | RFC 9457 problem details; `errors` names each field |
| `401` | No key, an unknown key, or a revoked or expired key | `WWW-Authenticate: X-Api-Key realm="Finology Public API"` |
| `429` | Monthly quota used up, or more than the per-key rate limit | Problem details; `detail` states calls used of limit and `upgradeUrl` names where the next tier is bought |
| `503` | The answer could not be written to the answer-of-record ledger | Problem details; retry shortly |

Quota is per key per calendar month, shared between the REST API and the MCP
server. Sandbox keys have 100 calls a month and expire after 30 days.
Production keys have 100,000 calls a month:
https://buy.stripe.com/28E4gy3LHf188KYd5kaZi01 ($499 a month, cancel any time,
key by email the moment payment clears).

## Not on this surface

Consolidation simulation, private loans, NSLDS file parsing and anything that
reads stored client data. Those live in the advisor application at
https://finology.tech/. Questions: support@finology.tech.
