SimpleFIN protocol reference

Wire-level reference for the BankSync SimpleFIN server: the claim exchange, endpoints, the superset Account Set, query parameters and limits.

5 min read

On this page

BankSync is a SimpleFIN server. This page is the wire-level reference for people writing or debugging a SimpleFIN client against it. If you just want to connect Actual Budget or Securo, the setup guide is shorter and has the per-app steps.

Root URL

https://api.banksync.io/simplefin. Every endpoint below is relative to it. The same paths are served on the staging host for integration testing.

The exchange#

A SimpleFIN setup token is standard base64 of a claim URL. A client decodes it, POSTs to the URL once, and receives an Access URL whose userinfo is the long-lived credential:

Shell
# 1. Decode the setup token to a claim URL, then POST to it (once).CLAIM_URL="$(echo "$SETUP_TOKEN" | base64 --decode)"ACCESS_URL="$(curl -s -X POST -H 'Content-Length: 0' "$CLAIM_URL")"# => https://<id>:<secret>@api.banksync.io/simplefin
# 2. Read accounts with the credentials embedded in that URL.curl -s "${ACCESS_URL}/accounts?version=2&pending=1"

Store the Access URL as securely as you would the financial data it returns — it is a bearer credential, and it is the only thing standing between the internet and the account history it can read. Setup tokens come from Settings → Developers → SimpleFIN in the app, and from the create_simplefin_access MCP tool.

Endpoints#

EndpointAuthReturns
GET /infonone{ "versions": ["1.0", "2.0"] }
GET /createnone302 to the BankSync app's create-access screen (the link SimpleFIN clients show for "get a token")
POST /claim/{token}none200 with the Access URL as plain text; 403 plain text starting with Forbidden for any invalid token
GET /accountsBasic200 with an Account Set; 403 for any credential problem; 429 with Retry-After when the daily budget is spent

A claim works exactly once. A second POST with the same token is refused with 403, which the protocol tells clients to surface as "this token may be compromised" — so if you see it during development, you pasted the same token twice. Claims are also rate-limited per IP.

/accounts returns a single 403 for every authentication failure — unknown id, wrong secret, revoked, rotated, never claimed. That is the protocol's design (it denies an attacker an oracle) and the body is still a well-formed Account Set with an errlist entry, so a client that parses before it checks the status gets something it can show.

The Account Set#

One response satisfies both dialects of the protocol, so a client written against either works without negotiating. Version 1 fields (org, errors) and version 2 fields (connections, conn_id, errlist) are both present in every response.

FieldWhat it holds
accounts[].idStable account identifier — link against this
accounts[].nameThe account name as BankSync shows it
accounts[].currencyISO 4217 code
accounts[].balanceDecimal string, never a float. Negative for credit cards, loans and mortgages
accounts[].available-balanceDecimal string, when the provider reports one
accounts[].balance-dateUnix seconds — when the institution last refreshed that balance, not when you asked
accounts[].org{ id, name, "sfin-url", domain?, url? } — v1 institution metadata
accounts[].conn_idKey into connections[] — v2 institution linkage
accounts[].transactionsOmitted entirely on balances-only=1; otherwise always an array
accounts[].holdingsInvestment positions: id, symbol, description, shares, market_value, cost_basis, purchase_price, currency, created
connections[]{ conn_id, name, org_id, org_name?, org_url?, sfin_url } — the same institutions in v2 shape
errors[]v1: human-readable strings, one per bank that could not be read
errlist[]v2: { code, msg, conn_id?, account_id? } with protocol error codes (con.auth, con., gen.)
x-api-message[]Per-request advice — a clamped window, a quota warning. Absent when there is none

Transactions carry id, posted (Unix seconds; 0 while pending), amount (signed decimal string), description, pending, and — where the provider gives them — payee, memo and transacted_at.

A bank that needs re-authentication is reported as con.auth with the message Connection to <bank> may need attention, and every healthy bank's data still ships. Actual pattern-matches that exact prefix to show its reconnect prompt, so the wording is stable.

Query parameters#

ParameterEffect
start-dateUnix epoch seconds, inclusive. Defaults to 30 days ago.
end-dateUnix epoch seconds, exclusive. Defaults to the end of today (UTC).
pending=1Include pending transactions. Excluded by default.
account=<id>Repeatable. Narrows the response to those accounts.
balances-only=1Skip transactions entirely — cheap for listing accounts.
version=2Accepted, but the response is the same superset either way.

Parameters are total: an unparseable value falls back to its default and is explained in x-api-message rather than failing the request. account= can narrow an access's scope but never widen it — asking for an account outside the grant returns nothing, not everything.

Freshness and limits

A request no more than 365 days wide is honoured; a wider one is clamped and explained in x-api-message. Responses are cached for 10 minutes (1 minute when any bank reported an error), so a client that polls aggressively still reads the banks at a sensible rate. Each access has a daily refresh budget (generous enough for hourly polling) that is charged on cache misses only, and is warned in x-api-message before it is throttled with 429.

Scope and lifecycle#

Each access is scoped to a workspace and, optionally, to a subset of its accounts; the scope can be narrowed or widened by an admin at any time and takes effect on the next read. Rotate issues a new setup token and invalidates the current Access URL immediately. Revoke ends the access permanently — the row stays for audit. Both are available in the app and via the rotate_simplefin_access / revoke_simplefin_access MCP tools.

Use this page with your AI assistant

Every BankSync doc is available as plain Markdown for agents and LLMs.