GuidesGetting Started with the API

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.

API access is a Premium feature and is currently in limited beta. Endpoints and setup may change while the beta is open.

Requirements

  • Tier: Premium. A request authenticated below Premium receives a tier-required problem 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"
Your credential is shown only once, at creation. Forbidden Finance stores only a fingerprint of it and cannot recover or re-display it. If you lose it, revoke the connection and create a new one.

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).

WhatWhere
Dynamic client registration (RFC 7591)POST /oauth/register
AuthorizationGET /oauth/authorize
TokenPOST /oauth/token
RevocationPOST /oauth/revoke

The details your client needs:

  • Grant types: authorization_code and refresh_token.
  • PKCE is required, and S256 is 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
}
FieldMeaning
dataThe payload. An object for single-item endpoints, an array for lists.
paginationPresent on list endpoints. Holds next_cursor and has_more.
redacted_fieldsField ids that this connection's privacy settings stripped from this response. Empty when nothing was removed.
filteredtrue 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.

SlugStatusWhen
validation400A parameter or body field is malformed, missing, or not allowed.
range-too-large400The requested date range exceeds the allowed span.
invalid-credential401Missing, unknown, expired, or revoked credential.
tier-required403The connection's owner is not on Premium. Carries current_tier, required_tier, and feature.
insufficient-scope403The connection lacks the scope this operation needs.
writes-disabled403A write was attempted on a connection whose Allow changes switch is off.
forbidden403The request was not permitted — including a write that would touch a field this connection's privacy settings hide.
not-found404The resource does not exist, or is hidden by this connection's exclusions. The two are byte-identical by design.
conflict409The request conflicts with current state — a stale expected_version, a bank-synced row you cannot delete, a system category, a split row.
partner-approval-required409A shared-budget change needs the partner's in-app approval.
field-immutable422A field in the request cannot be changed at all — currently, a transaction's currency.
data-excluded422An aggregate cannot be computed because exclusions remove part of its input.
rate-limited429Too many requests. Carries Retry-After.
quota-exceeded429The daily request quota is used up. Carries Retry-After.
internal500An unexpected error.
unavailable503Temporarily 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_size to size a page. The cap is 200; ask for more and you get 200.
  • When pagination.has_more is true, pass pagination.next_cursor back as the cursor query 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.

LimitValueScope
Reads120 per minutePer connection
Writes30 per minutePer connection
All requests300 per minutePer account, shared across every connection
Daily quota10,000 requestsPer 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].