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
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:
# 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#
| Endpoint | Auth | Returns |
|---|---|---|
GET /info | none | { "versions": ["1", "2"] } |
GET /create | none | 302 to the BankSync app's create-access screen (the link SimpleFIN clients show for "get a token") |
POST /claim/{token} | none | 200 with the Access URL as plain text; 403 plain text starting with Forbidden for any invalid token |
GET /accounts | Basic | 200 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.
| Field | What it holds |
|---|---|
accounts[].id | Stable account identifier — link against this |
accounts[].name | The account name as BankSync shows it |
accounts[].currency | ISO 4217 code |
accounts[].balance | Decimal string, never a float. Negative for credit cards, loans and mortgages |
accounts[].available-balance | Decimal string, or null when the provider reports none. On a credit card it is available credit |
accounts[].balance-date | Unix 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_id | Key into connections[] — v2 institution linkage |
accounts[].transactions | Omitted entirely on balances-only=1; otherwise always an array |
accounts[].holdings | Investment 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#
| Parameter | Effect |
|---|---|
start-date | Unix epoch seconds, inclusive. Defaults to 30 days ago. |
end-date | Unix epoch seconds, exclusive. Defaults to the end of today (UTC). |
pending=1 | Include pending transactions. Excluded by default. |
account=<id> | Repeatable. Narrows the response to those accounts. |
balances-only=1 | Skip transactions entirely — cheap for listing accounts. |
version=2 | Accepted, but the response is the same superset either way. |
extra=1 | Adds 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.
Related#
- Connect Actual Budget, Securo and other SimpleFIN apps — the user guide
- API keys — for your own scripts against the BankSync REST API
- MCP server — for AI agents
Use this page with your AI assistant
Every BankSync doc is available as plain Markdown for agents and LLMs.