SimpleFIN protocol reference

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

6 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", "2"] }
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. A spent daily budget still returns 200, with last-known balances and a gen. error

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, or null when the provider reports none. On a credit card it is available credit
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. BankSync aggregates positions, so created is always null
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, payee, memo, transacted_at and pending.

Every field above is always present. Several are optional in the SimpleFIN specification, but strict clients — including the simplefin4py library behind the Home Assistant integration — read them unconditionally and fail the whole response on a missing key. BankSync therefore always emits the key and uses null where it has no value, rather than inventing one. payee is the exception: it is always a non-empty string, falling back to the description, because Actual refuses to import a transaction without one.

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.
extra=1Adds BankSync's extra objects (account type, bank id, a card's available credit, Plaid's pending-transaction link). Off by default so the response matches what SimpleFIN clients are written against.

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, or when a workspace that has a bank connected comes back with no accounts at all), 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. It is warned about in x-api-message first; once it is spent, requests still return 200 with the last known balances and a gen. error explaining why the numbers stopped moving, because the protocol has no status for "slow down" and an error status makes clients tell users to relink their bank.

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.