Getting Started with the API
Authenticate, make your first request, and understand the response envelope, error format, money encoding, pagination, and rate limits for the Forbidden Finance developer API. Premium, limited beta.
Overview
The Forbidden Finance developer API lets you read and, where you allow it, edit your own financial data — accounts, transactions, categories, budgets, goals, net worth, and insights — from your own scripts, tools, or AI assistant.
The surface is 40 operations: 29 reads and 11 writes. Reads are the default; every write needs a scope you granted, a switch you armed, and an idempotency key you chose.
Every connection you create is a privacy gateway. You decide which fields are stripped and which accounts or categories are hidden before any data leaves Forbidden Finance. See Data Privacy & Redaction for how that works.
Requirements
- Tier: Premium. A request authenticated below Premium receives a
tier-requiredproblem response. - A connection: create one under Settings > AI & API Connections in the app. Each connection carries its own scopes and its own privacy settings. See AI & API Connections.
- A credential: either an API key (
ff_ak_…) or an OAuth 2.1 access token (ff_at_…). Your key is shown once, when you create the connection.
Base URL
https://api.403fin.io
Every endpoint is mounted under /v1 — so accounts live at https://api.403fin.io/v1/accounts. All responses are JSON. All timestamps are RFC 3339 in UTC.
The full machine-readable spec is served, unauthenticated, at https://api.403fin.io/v1/openapi.json. That document is generated by the running server, so it is always current — point your code generator at it rather than at a copy.
Authentication
There are two ways in. Pick the API key if you are writing a script for yourself; pick OAuth if you are building something other people will connect.
API key
Create a connection in the app and pass the ff_ak_ key it gives you as a Bearer token:
curl https://api.403fin.io/v1/accounts \
-H "Authorization: Bearer ff_ak_your_key_here"
The X-API-Key header works identically, if that is easier for your tooling:
curl https://api.403fin.io/v1/accounts \
-H "X-API-Key: ff_ak_your_key_here"
OAuth 2.1
For an app that connects on someone else's behalf, Forbidden Finance is an OAuth 2.1 authorization server at https://api.403fin.io. Discovery metadata lives at /.well-known/oauth-authorization-server (RFC 8414), and the MCP endpoint advertises its authorization server at /.well-known/oauth-protected-resource/mcp (RFC 9728).
| What | Where |
|---|---|
| Dynamic client registration (RFC 7591) | POST /oauth/register |
| Authorization | GET /oauth/authorize |
| Token | POST /oauth/token |
| Revocation | POST /oauth/revoke |
The details your client needs:
- Grant types:
authorization_codeandrefresh_token. - PKCE is required, and
S256is the only accepted method. - Client authentication is
none— clients are public, so there is no client secret to store. - Register dynamically; there is no manual app-registration step to wait on.
At the authorization step the user lands on a Forbidden Finance consent screen where they pick the connection's scopes, decide whether changes are allowed, and choose any fields to hide. Your client never sees or chooses those settings — it receives whatever the user granted.
Access tokens (ff_at_…) are short-lived and refresh tokens (ff_rt_…) rotate on each use. Present an access token exactly like an API key, as a Bearer token.
The response envelope
Every successful response — read or write — is wrapped in the same envelope:
{
"data": [ ],
"pagination": { "next_cursor": "eyJ…", "has_more": true },
"redacted_fields": ["transaction.merchant"],
"filtered": true
}
| Field | Meaning |
|---|---|
data | The payload. An object for single-item endpoints, an array for lists. |
pagination | Present on list endpoints. Holds next_cursor and has_more. |
redacted_fields | Field ids that this connection's privacy settings stripped from this response. Empty when nothing was removed. |
filtered | true when account or category exclusions changed which rows or totals you see. |
redacted_fields and filtered are how the API tells you, plainly, when the connection's own privacy settings shaped the result. See Data Privacy & Redaction.
Errors
Errors are RFC 9457 problem documents, served as application/problem+json:
{
"type": "https://api.403fin.io/problems/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "The connection lacks the scope required for this operation.",
"request_id": "req_01H…"
}
The slug at the tail of type is the stable, machine-readable identifier. Match on the slug, not on title or detail — those are prose and may be reworded.
| Slug | Status | When |
|---|---|---|
validation | 400 | A parameter or body field is malformed, missing, or not allowed. |
range-too-large | 400 | The requested date range exceeds the allowed span. |
invalid-credential | 401 | Missing, unknown, expired, or revoked credential. |
tier-required | 403 | The connection's owner is not on Premium. Carries current_tier, required_tier, and feature. |
insufficient-scope | 403 | The connection lacks the scope this operation needs. |
writes-disabled | 403 | A write was attempted on a connection whose Allow changes switch is off. |
forbidden | 403 | The request was not permitted — including a write that would touch a field this connection's privacy settings hide. |
not-found | 404 | The resource does not exist, or is hidden by this connection's exclusions. The two are byte-identical by design. |
conflict | 409 | The request conflicts with current state — a stale expected_version, a bank-synced row you cannot delete, a system category, a split row. |
partner-approval-required | 409 | A shared-budget change needs the partner's in-app approval. |
field-immutable | 422 | A field in the request cannot be changed at all — currently, a transaction's currency. |
data-excluded | 422 | An aggregate cannot be computed because exclusions remove part of its input. |
rate-limited | 429 | Too many requests. Carries Retry-After. |
quota-exceeded | 429 | The daily request quota is used up. Carries Retry-After. |
internal | 500 | An unexpected error. |
unavailable | 503 | Temporarily unavailable. Retry with backoff. |
Two of these deserve a note. A not-found never tells you whether something is hidden or absent — that is deliberate, so a connection cannot map what it was denied by probing ids. And forbidden on a write never names the field it refused, for the same reason: which fields are hidden is itself the configuration.
Money
Monetary values are never floating-point numbers. Each amount is an object holding an exact decimal string and an ISO 4217 currency code:
{ "amount": "1234.56", "currency": "USD" }
Parse amount with a decimal library, not a float. The string is exact and byte-stable: "0.00" and a 19-digit balance both come through unchanged. Some payloads carry a bare decimal string instead of the pair — budget allocations and goal targets, for example, where the currency is fixed by the parent object. The same rule applies: it is a string, and it is exact.
Pagination
List endpoints use opaque cursor pagination.
- Pass
page_sizeto size a page. The cap is 200; ask for more and you get 200. - When
pagination.has_moreistrue, passpagination.next_cursorback as thecursorquery parameter. - Total counts are intentionally not exposed.
curl "https://api.403fin.io/v1/transactions?page_size=100&cursor=eyJ…" \
-H "Authorization: Bearer ff_ak_your_key_here"
Follow next_cursor until has_more is false. Treat the cursor as opaque — do not parse it, and do not hold one across a page-size change.
Rate limits
Limits are enforced per connection, with a shared per-account ceiling. Reads and writes draw on separate per-connection budgets.
| Limit | Value | Scope |
|---|---|---|
| Reads | 120 per minute | Per connection |
| Writes | 30 per minute | Per connection |
| All requests | 300 per minute | Per account, shared across every connection |
| Daily quota | 10,000 requests | Per connection, per UTC day |
Exceeding a per-minute limit gives you rate-limited; exhausting the daily quota gives you quota-exceeded. Both carry a Retry-After header — honor it rather than retrying tightly. Design polling to be gentle: a job that sweeps transactions once an hour will never come close to these numbers, and one that polls every second will.
Your first request
curl https://api.403fin.io/v1/accounts \
-H "Authorization: Bearer ff_ak_your_key_here"
You get an envelope whose data is an array of accounts, each with a balance in the money shape above.
Attribution takes two hops, and it is worth knowing before you build anything that names a bank. A transaction carries account_id and nothing more about where it came from. An account carries connection_id, plus institution_name only when its own bank differs from its connection's — one connection can span several banks. A connection carries institution, provider (the rail: plaid, quiltt, manual) and, where that provider fronts another aggregator, source_aggregator.
So to name the bank behind a transaction: resolve the account, take account.institution_name if present and the connection's institution otherwise. To name the pipe the data arrived through: read source_aggregator ?? provider on that connection. Manual accounts have no connection at all.
Next steps
Scopes & Permissions
The 16 scopes, write implication, and the two-switch rule.Writing Data
Idempotency, versioning, and every write endpoint with examples.Data Privacy & Redaction
Control exactly what a connection can see.MCP Server Reference
The hosted MCP endpoint and its 40 tools.Browse every endpoint, parameter, and schema — with a live playground — in the API Reference section of this tab.
Need more help? Contact us at [email protected].