Fintable connects to your banks and syncs your accounts, balances, transactions, and investment holdings into spreadsheets such as Google Sheets or Airtable. The Fintable API V2 opens that same data (and more!) up to your own code: everything stored in Fintable — bank connections, accounts, transactions, investment holdings, the categorizer, and your spreadsheet integrations — is available over a clean REST interface.
The Fintable API is designed to be a one-stop shop for you to build your own finance app (for yourself, not to resell) from scratch.
Private Data API
Used to retrieve your private financial data such as your bank account balances and transactions.
Public Data API
Public financial data that is very useful for building full-featured financial apps and dashboards. Today that's the institution directory and the docs-as-data endpoints; live currency exchange rates and stock prices are planned and coming soon.
Dashboard / Management API
Used to manage Fintable itself, create new bank connections and check their sync status, so you don't even have to login to the Fintable dashboard at all.
No third-party access for platforms or apps - your data only
The Fintable API is strictly first-party for bank accounts that you own or are authorized to control directly (such as your client's accounts if you are an accountant). It is not a data-aggregation platform like Plaid — it cannot and must not be used to create finance apps for resale to others, only for yourself.
Getting Started
Never used Fintable before? Here's the whole journey, from nothing to your first API call over your own bank data.
| Base URL | https://fintable.io/api/v2 |
| OpenAPI 3.1 description | https://fintable.io/api/v2/openapi.json |
| MCP server for AI assistants | https://fintable.io/mcp |
| AI Skill or LLM docs (llms.txt) | https://fintable.io/llms.txt |
| Manage tokens | Dashboard → API |
1. Create a Fintable
Sign up here — it's free to start, and the free tier includes API access, so you can build against the API before paying for anything.
2. Create a Personal Access Token
Open Dashboard → API and create a Personal Access Token, choosing read-only or read & write access. The token is shown exactly once, so copy it somewhere safe and treat it like a password. This token is what your scripts will send to authenticate as you.
3. Connect a bank account
Mint the link, then visit the URL it gives you and follow the steps in a browser to complete the bank connection flow:
curl -X POST https://fintable.io/api/v2/connections/link \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json"
{
"data": {
"url": "https://fintable.io/api-link/eyJpdiI6...",
"expires_at": "2026-07-26T15:42:00Z"
}
}
The single-use link expires in 30 minutes; once you're through, Fintable starts syncing the bank's accounts and
transactions. Full details (institution pre-selection, reconnects, eligibility) are at
POST /connections/link.
4. Retrieve your balances and transactions
Once the first sync lands — usually within a couple of minutes — your data is there. Balances:
curl https://fintable.io/api/v2/accounts \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": [
{
"id": "acc_01J9V5R9WQD3M8Y2KXN4T7PB6C",
"name": "Chase Total Checking",
"balance": "5240.12",
"balance_available": "5190.12",
"currency": "USD",
"...": "..."
}
]
}
And transactions:
curl "https://fintable.io/api/v2/transactions?limit=5" \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": [
{
"id": "tx_01JB2M9QK4R7X3W8N5PDY6TF2H",
"date": "2026-07-24",
"amount": "-4.50",
"currency": "USD",
"description": "BLUE BOTTLE COFFEE",
"...": "..."
}
],
"next_cursor": null
}
The full shapes, filters, and pagination live in Account and
Transaction — and the whole API Reference follows the same pattern. Prefer generated
clients? Point Swagger UI, Postman, or your code generator at the OpenAPI 3.1 description:
https://fintable.io/api/v2/openapi.json.
Authentication
There are two ways in, depending on what you're building:
- Personal Access Tokens — for your own scripts and tools. Create one in the dashboard, drop it in a header, done.
- OAuth 2.0 — for apps and AI assistants that connect to your account through a proper authorization flow (this is what the MCP server uses under the hood).
Personal Access Tokens
Create and revoke tokens on the Dashboard → API
page. Tokens are valid for 1 year and carry the scopes you pick at creation — read-only (read) or read & write
(read + write). Send them as a bearer header:
Authorization: Bearer YOUR_TOKEN
Revocation takes effect immediately.
OAuth 2.0
Fintable runs a standard OAuth 2.0 authorization server, so any off-the-shelf OAuth client library will work:
| Endpoint | URL |
|---|---|
| Authorize | https://fintable.io/oauth/authorize |
| Token | https://fintable.io/oauth/token |
| Dynamic client registration | https://fintable.io/oauth/register |
| Discovery | https://fintable.io/.well-known/oauth-authorization-server |
A few specifics worth knowing:
- Authorization Code + PKCE is the supported grant.
- Access tokens last 1 hour; refresh tokens last 30 days.
- Authorizing requires being logged in, and the consent screen states exactly what the app will be able to do.
Scopes
| Scope | What it allows |
|---|---|
read |
Read all data on the account |
write |
Modify data — rename, categorize, enable/disable, delete, sync |
mcp:use |
Full read and write access, for MCP clients (Claude, ChatGPT) |
mcp:use is a superset: it is accepted everywhere read or write is. Plain
read/write tokens are rejected by the MCP endpoint.
API Reference
Everything below uses the same bearer authentication, and the shared behaviors — envelopes, money strings, errors, rate limits, pagination — are described in API Conventions and Pagination further down. Each section covers one resource type: what it is, its exact shape (a realistic example and every field explained), then the endpoints that operate on it.
Profile
| Endpoint | What it does |
|---|---|
GET /me |
Your profile and billing metadata |
The Profile is your account as the API sees it: who you are, what plan you're on, and how much headroom you have left. Check it before adding a connection or triggering a sync — the limits it reports are the ones the write endpoints enforce.
{
"data": {
"name": "Jamie",
"tier": "personal",
"plan_period": "monthly",
"connection_limit": 10,
"connections_used": 3,
"tx_365_limit_usd": null,
"can_sync": true,
"renews_at": "2026-08-14T00:00:00Z",
"renewal_amount": "9.00",
"renewal_currency": "USD",
"will_renew": true,
"expires_at": "2026-08-14T00:00:00Z"
}
}
| Field | Type | Meaning |
|---|---|---|
name |
string | Your display name |
tier |
string | Plan tier: free, trial, personal, office, or enterprise |
plan_period |
string | null | Billing cadence: monthly, annual, lifetime, trial, or manual; null on free accounts |
connection_limit |
integer | Cap on how many bank connections your plan allows in total. Adding a connection fails when connections_used has already reached this number. Free accounts report their current count as the limit (no spare slots). |
connections_used |
integer | How many bank connections are on your account right now. Compare with connection_limit for remaining headroom: connection_limit - connections_used. Disconnecting a bank lowers this; the limit itself does not change unless your plan does. |
tx_365_limit_usd |
integer | null | Rolling 365-day transaction-volume limit in USD; null means unlimited |
can_sync |
boolean | Whether syncs are available (false on free accounts) |
renews_at |
string | null | ISO-8601 time the active subscription renews |
renewal_amount |
string | null | Renewal price, as a decimal string |
renewal_currency |
string | null | Renewal currency code |
will_renew |
boolean | Whether the subscription will auto-renew |
expires_at |
string | null | When the current entitlement ends |
Free accounts get "tier": "free", "can_sync": false, and their current connection count as the limit.
GET /me
Returns your Profile — the object above. No parameters:
curl https://fintable.io/api/v2/me \
-H "Authorization: Bearer YOUR_TOKEN"
Connection
| Endpoint | What it does |
|---|---|
GET /connections |
List all connections |
GET /connections/{id} |
One connection |
PATCH /connections/{id} |
Rename or set the sync start date |
DELETE /connections/{id} |
Disconnect the bank and purge its data |
POST /connections/link |
Mint a browser link to connect a new bank |
POST /connections/{id}/link |
Mint a browser link to reconnect this bank |
A Connection is one linked bank — one login at one institution. A connection owns one or more accounts, and carries the health and sync state for that bank relationship.
{
"data": {
"id": "conn_plaid_1771845993762884095",
"provider": "PLAID",
"institution_name": "Chase",
"name": null,
"healthy": true,
"status_text": "OK",
"needs_reconnect": false,
"last_successful_update": "2026-07-26T09:12:44Z",
"created_at": "2025-11-02T18:20:11Z",
"accounts_count": 3,
"sync_status": {
"state": "finished",
"progress_now": 4,
"progress_max": 4,
"stage": "Sync complete",
"started_at": "2026-07-26T09:11:58Z",
"finished_at": "2026-07-26T09:12:44Z"
}
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Connection id, conn_{provider}_{number} |
provider |
string | The aggregator behind this connection, e.g. PLAID, NORDIGEN, AKOYA, FINICITY, MERCURY, SNAPTRADE |
institution_name |
string | The bank's name (your custom name, when set) |
name |
string | null | Your custom name for the connection |
healthy |
boolean | Stored health signal — false means the connection needs attention |
status_text |
string | Human-readable status: OK, or the provider's error message |
needs_reconnect |
boolean | true when the bank requires you to re-authenticate |
last_successful_update |
string | null | ISO-8601 time of the last successful sync |
created_at |
string | When the bank was connected |
accounts_count |
integer | Number of accounts under this connection |
sync_status |
object | null | The most recent sync job — a Sync Status object |
GET /connections
Lists all your connections. No parameters.
GET /connections/{id}
One connection by id.
PATCH /connections/{id}
Rename the connection or set its sync start date. Accepts either or both of:
name— your custom name, max 64 characters;nullclears it.sync_start_date—YYYY-MM-DD;nullclears it. Fintable will only sync transactions from this date forward.
curl -X PATCH https://fintable.io/api/v2/connections/conn_plaid_1771845993762884095 \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Chase (Jamie)", "sync_start_date": "2026-01-01"}'
The response is the updated connection object. Two details: the start date applies to the connection's enabled accounts only (disabled accounts keep their date until re-enabled), and the date is validated against provider minimum-history and trial 30-day limits (422 if out of range).
DELETE /connections/{id}
Disconnects the bank and purges its accounts and transactions from Fintable. Because the purge involves the provider, it completes asynchronously — the response is a 202:
{
"data": {
"id": "conn_plaid_1771845993762884095",
"status": "deleting"
}
}
The connection and its data are gone within minutes.
POST /connections/link
Connecting a bank means logging into it, and bank login pages need a real browser — so this is the one flow the API can't complete alone. Instead, this endpoint mints a single-use URL, valid for 30 minutes, that you open yourself (or hand to the account owner — it's their account):
curl -X POST https://fintable.io/api/v2/connections/link \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"institution": "12913262_chase"}'
{
"data": {
"url": "https://fintable.io/api-link/eyJpdiI6...",
"expires_at": "2026-07-26T15:42:00Z"
}
}
The institution field is optional — it's a slug from the
Institution directory, and pre-selects the bank in the flow. Whoever opens the URL sees which Fintable
account they're connecting to, confirms the account password, and lands in the provider's bank-selection flow. The URL
burns on first successful confirmation.
Minting requires an active plan with headroom: an active subscription or trial, under the connection limit, under the monthly attempt limit, and under the transaction-volume limit — 422 with an explanation otherwise (and the same checks run again when the bank is actually created).
POST /connections/{id}/link
Works exactly like POST /connections/link but mints a reconnect link for an existing
connection, and is exempt from the new-connection checks.
Account
| Endpoint | What it does |
|---|---|
GET /accounts |
List all accounts, including disabled ones |
GET /accounts/{id} |
One account |
PATCH /accounts/{id} |
Update display_name, sync_start_date, and/or enabled |
An Account is one individual bank account inside a connection — a checking account, a savings account, a brokerage. Accounts are where balances live and what transactions belong to.
{
"data": {
"id": "acc_01J9V5R9WQD3M8Y2KXN4T7PB6C",
"connection_id": "conn_plaid_1771845993762884095",
"name": "Chase Total Checking",
"display_name": "Household checking",
"type": "depository / checking",
"currency": "USD",
"balance": "5240.12",
"balance_available": "5190.12",
"sync_start_date": "2026-01-01",
"last_tx_date": "2026-07-25",
"enabled": true,
"created_at": "2025-11-02T18:20:14Z",
"updated_at": "2026-07-26T09:12:40Z"
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Account id — opaque, usually acc_... |
connection_id |
string | The Connection this account belongs to |
name |
string | The bank's name for the account |
display_name |
string | null | Your custom name — this is what your spreadsheets show |
type |
string | Provider-flavored free text like depository / checking or investment / brokerage — display it, don't switch on it |
currency |
string | Effective currency code (honors any override you set) |
balance |
string | null | Current balance as a decimal string |
balance_available |
string | null | Available balance, when the bank reports one |
sync_start_date |
string | null | YYYY-MM-DD — transactions are only synced from this date forward |
last_tx_date |
string | null | Date of the newest synced transaction |
enabled |
boolean | Whether the account syncs; disabled accounts keep appearing here with enabled: false |
created_at / updated_at |
string | ISO-8601 timestamps |
GET /accounts
Lists every account, including disabled ones (enabled: false). Filters:
connection_id, ids[], and enabled.
GET /accounts/{id}
One account by id.
PATCH /accounts/{id}
Updates display_name, sync_start_date, and/or enabled.
Warning: disabling an account deletes its transactions. Setting
"enabled": falsepermanently deletes all of that account's transactions in Fintable — identical to the dashboard toggle. Re-enabling does not restore them; a later sync has to backfill from the provider. Don't disable an account unless that is exactly what you want.
Holding
| Endpoint | What it does |
|---|---|
GET /accounts/{id}/holdings |
One snapshot of an account's holdings |
A Holding is one position in an investment account — a stock, fund, or other security. Fintable records holdings as
daily snapshots: what you held, at what price, once per day. A holdings response is the set of rows for one snapshot
date, with the date on the envelope as snapshot_date.
{
"data": [
{
"id": "hol_01JB7Q2M5X8R4T6W9NKZP3VD1F",
"name": "Vanguard Total Stock Market ETF",
"symbol": "VTI",
"quantity": "42.0000",
"price": "279.35",
"value": "11732.70",
"cost_basis": "9450.00",
"currency": "USD",
"updated_at": "2026-07-26T09:12:41Z"
}
],
"snapshot_date": "2026-07-26"
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Holding id — opaque, usually hol_... |
name |
string | The security's name |
symbol |
string | null | Ticker symbol, when the provider reports one |
quantity |
string | null | Units held, as a decimal string |
price |
string | null | Price per unit |
value |
string | null | Current market value of the position |
cost_basis |
string | null | Total cost of the position, not per-share — a provider quirk we pass through rather than guess at |
currency |
string | The account's effective currency |
updated_at |
string | null | When this row was last written |
snapshot_date (envelope) |
string | null | The snapshot day this response describes; null when the account has no holdings |
GET /accounts/{id}/holdings
Returns the latest snapshot by default; ?date=YYYY-MM-DD selects a specific one. There is no history pagination —
fetch date by date.
Transaction
| Endpoint | What it does |
|---|---|
GET /transactions |
All transactions, cursor-paged |
GET /accounts/{id}/transactions |
One account's transactions |
GET /transactions/{id} |
One transaction |
PATCH /transactions/{id} |
Set or clear the category |
PATCH /transactions/bulk |
Categorize many at once |
The heart of the API. A Transaction is one movement of money on an account: a purchase, a deposit, a transfer, a fee. Transactions carry the categorization state — both the assigned category and whether it was set by hand or by a rule.
{
"data": {
"id": "tx_01JB2M9QK4R7X3W8N5PDY6TF2H",
"account_id": "acc_01J9V5R9WQD3M8Y2KXN4T7PB6C",
"date": "2026-07-24",
"datetime": "2026-07-24T16:41:02Z",
"auth_date": "2026-07-23",
"amount": "-4.50",
"currency": "USD",
"description": "BLUE BOTTLE COFFEE",
"merchant": "Blue Bottle Coffee",
"pending": false,
"check_num": null,
"external_memo": null,
"account_owner": null,
"category": {
"id": "dining-out_aB3xY9k2Lm",
"name": "Dining Out",
"header": "Expenses"
},
"category_manual_override": false,
"created_at": "2026-07-24T18:03:12Z",
"updated_at": "2026-07-25T06:14:09Z"
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Transaction id — opaque, usually tx_... |
account_id |
string | The Account this transaction belongs to |
date |
string | Transaction date, YYYY-MM-DD |
datetime |
string | null | Exact ISO-8601 time, when the provider supplies one |
auth_date |
string | null | Authorization date, when it differs from the posted date |
amount |
string | Exact decimal string; negative is money out |
currency |
string | Effective currency code |
description |
string | The statement description |
merchant |
string | null | Cleaned-up merchant name, when known |
pending |
boolean | true while the transaction hasn't posted — pending rows can be replaced when they post |
check_num |
string | null | Check number, for check payments |
external_memo |
string | null | Extra memo text from the bank |
account_owner |
string | null | Owner name on shared/multi-owner accounts |
category |
object | null | The assigned Category — {id, name, header} — or null when uncategorized |
category_manual_override |
boolean | true when the category was set by hand; rules never touch overridden rows |
created_at / updated_at |
string | ISO-8601 timestamps; updated_at drives incremental sync |
raw |
object | The provider's raw JSON — only present with ?include=raw; the same data as your spreadsheets' **Raw fields |
GET /transactions
All your transactions, cursor-paged, newest first by default. Filters:
| Filter | Meaning |
|---|---|
date_from, date_to |
Date range (YYYY-MM-DD, inclusive) |
account_ids[] |
Limit to specific accounts |
category_ids[] |
Limit to categories — include the literal uncategorized for uncategorized rows |
pending |
true or false |
amount_min, amount_max |
Amount range |
q |
Case-insensitive substring search over description + merchant |
description |
Exact description match |
updated_since, order |
For incremental sync; order is date or updated |
GET /accounts/{id}/transactions
One account's transactions — same filters, pagination, and shape as
GET /transactions.
GET /transactions/{id}
One transaction by id. ?include=raw works here too.
PATCH /transactions/{id}
Does exactly one thing — sets the category:
curl -X PATCH https://fintable.io/api/v2/transactions/tx_01JB2M9QK4R7X3W8N5PDY6TF2H \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"category_id": "dining-out_aB3xY9k2Lm"}'
Setting a category marks the transaction as manually overridden
(category_manual_override: true) — rules will never touch it again. Setting
"category_id": null uncategorizes it and clears the override, so rules may re-apply on the next pass. The response
is the updated transaction.
PATCH /transactions/bulk
Applies one category_id (or null) to many transactions at once. Pick your targets with exactly one of two
selectors:
ids[]— up to 10,000 ids (ids you don't own are silently skipped), orfilters— the same keys as the list endpoint. To keep a typo from recategorizing your entire history, the filter must include at least one narrowing key —date_from,date_to,account_ids,category_ids,q, ordescription(pendingandamount_*may refine but don't count on their own). If more than 10,000 transactions match, the request fails with 422 — narrow and retry.
Not sure what a filter will hit? Do a dry run first:
curl -X PATCH https://fintable.io/api/v2/transactions/bulk \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filters": {"q": "whole foods", "date_from": "2026-01-01"},
"category_id": "groceries_x7Pq2Rv9Zn",
"dry_run": true
}'
{
"data": {
"dry_run": true,
"matched_count": 37,
"sample": [
"... up to 10 matching transactions ..."
]
}
}
Happy with the match? Send the same request without dry_run:
{
"data": {
"dry_run": false,
"updated_count": 37
}
}
Execution updates all matched rows and syncs the categories to your spreadsheets once, as a single export.
Sync
| Endpoint | What it does |
|---|---|
GET /sync |
Schedule, live sync jobs, and per-connection status |
POST /sync |
Sync all connections now |
POST /sync/{connection_id} |
Sync one connection now |
A Sync is one run of pulling fresh data from a bank connection into Fintable. Fintable schedules these automatically — every account is on a randomized sweep that runs every 6–23 hours, so there is deliberately no exact "next sync time". The API lets you inspect the schedule, watch running syncs, and (on paid plans) trigger one on demand.
The recurring shape here is the Sync Status object — it appears on each
Connection and throughout GET /sync:
{
"state": "finished",
"progress_now": 4,
"progress_max": 4,
"stage": "Sync complete",
"started_at": "2026-07-26T09:11:58Z",
"finished_at": "2026-07-26T09:12:44Z"
}
| Field | Type | Meaning |
|---|---|---|
state |
string | queued, executing, finished, failed, or retrying |
progress_now |
integer | null | Steps completed so far |
progress_max |
integer | null | Total steps in this run |
stage |
string | null | Human-readable description of the current stage |
started_at |
string | null | When the run began |
finished_at |
string | null | When the run ended; null while still going |
GET /sync
Wraps the Sync Status objects in the full picture — your schedule plus per-connection status:
| Field | Type | Meaning |
|---|---|---|
schedule.type |
string | default (the randomized sweep) or custom (provider-specific schedules exist) |
schedule.last_sync_at |
string | null | When the sweep last dispatched your syncs |
schedule.next_sync_window |
object | null | Approximate {earliest, latest} window for the next sweep — no exact time exists |
schedule.custom_schedules |
array | Provider-specific schedules: {provider, cron, timezone, next_run_at} |
schedule.default_sweep_applies |
boolean | The default sweep applies to every account, custom schedules or not |
active_syncs |
array | Currently running (or stuck, or failed) jobs: {connection_id, sync_status} |
connections |
array | Every connection's latest {connection_id, sync_status} |
curl https://fintable.io/api/v2/sync \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": {
"schedule": {
"type": "default",
"last_sync_at": "2026-07-26T09:11:55Z",
"next_sync_window": {
"earliest": "2026-07-26T15:23:00Z",
"latest": "2026-07-27T09:11:55Z"
},
"custom_schedules": [],
"default_sweep_applies": true
},
"active_syncs": [],
"connections": [
{
"connection_id": "conn_plaid_1771845993762884095",
"sync_status": {
"state": "finished",
"progress_now": 4,
"progress_max": 4,
"stage": "Sync complete",
"started_at": "2026-07-26T09:11:58Z",
"finished_at": "2026-07-26T09:12:44Z"
}
}
]
}
}
POST /sync
The API version of the dashboard's "Sync All Connections" button. It pulls the provider's cached data — it is not a realtime refresh from the bank itself:
{
"data": [
{
"connection_id": "conn_plaid_1771845993762884095",
"status": "started"
},
{
"connection_id": "conn_nordigen_1802214467911184310",
"status": "already_syncing"
}
]
}
A sync already in progress is reported as already_syncing, never as an error. Requires an active subscription or
trial — free accounts get a 403 before anything starts. Watch progress via GET /sync or the
sync_status on each connection.
POST /sync/{connection_id}
Syncs just one connection — same response shape as POST /sync (one element), same entitlement
requirement.
Category
| Endpoint | What it does |
|---|---|
GET /categorizer/categories |
List categories |
GET /categorizer/categories/{id} |
One category |
POST /categorizer/categories |
Create a category (201) |
PATCH /categorizer/categories/{id} |
Rename, re-header, recolor |
DELETE /categorizer/categories/{id} |
Delete a category |
A Category is a label for transactions — the building block of the categorizer, which is how transactions get labeled: categories are the labels, and Rules apply them automatically as transactions sync in. Everything you can do on the categorizer dashboard you can do here. Limit: 1,000 categories per account.
{
"data": {
"id": "groceries_x7Pq2Rv9Zn",
"name": "Groceries",
"header": "Expenses",
"color": "green",
"created_at": "2026-07-26T14:02:33Z",
"updated_at": "2026-07-26T14:02:33Z"
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Category id, {name-slug}_{10 alphanumerics} — stable across renames |
name |
string | The label shown on transactions and in your spreadsheets |
header |
string | The group the category appears under, like Expenses or Income |
color |
string | A name from the dashboard palette: red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose |
created_at / updated_at |
string | ISO-8601 timestamps |
GET /categorizer/categories
Lists all your categories.
GET /categorizer/categories/{id}
One category by id.
POST /categorizer/categories
Creates a category (201) — name, header, and an optional color:
curl -X POST https://fintable.io/api/v2/categorizer/categories \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Groceries", "header": "Expenses", "color": "green"}'
The response is the created category (as above).
PATCH /categorizer/categories/{id}
Updates name, header, and/or color. Renames propagate to your Airtable and Google Sheets automatically.
DELETE /categorizer/categories/{id}
Deleting is guarded: if any rule still references the category, the request fails with 409 listing the offending rule ids — update or delete those rules first. Otherwise, all transactions in the category become uncategorized and the category is deleted:
{
"data": {
"deleted": true,
"uncategorized_count": 118
}
}
Rule
| Endpoint | What it does |
|---|---|
GET /categorizer/rules |
List rules — metadata only, no logic |
GET /categorizer/rules/{id} |
One rule, including its full logic |
POST /categorizer/rules |
Create a rule (202) |
PATCH /categorizer/rules/{id} |
Update name, priority, and/or logic |
DELETE /categorizer/rules/{id} |
Delete a rule |
POST /categorizer/sync |
Queue a full rules pass + spreadsheet export (202) |
GET /categorizer/status |
Where the pipeline stands |
A Rule categorizes transactions automatically as they sync in — the other half of the categorizer. Limit: 1,000 rules per account.
{
"data": {
"id": "a25a374d-e4d7-4652-aca7-5dd3c3d02d15",
"name": "Big grocery runs",
"type": "advanced",
"priority": 7,
"category_ids": [
"groceries_x7Pq2Rv9Zn"
],
"logic": {
"if": [
"..."
]
},
"created_at": "2026-07-26T14:10:05Z",
"updated_at": "2026-07-26T14:10:05Z"
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Rule id — a UUID |
name |
string | Display name (auto-generated for simple rules) |
type |
string | simple (description-contains-text) or advanced (raw JSONLogic) |
priority |
integer | Rules run in (priority, id) order; when several match, the higher-priority rule wins |
category_ids |
array of strings | The Categories this rule's branches can assign |
logic |
object | The full JSONLogic — present on GET /categorizer/rules/{id} and create/update responses, omitted from the list |
created_at / updated_at |
string | ISO-8601 timestamps |
How rules behave — worth reading once:
- Rules run in (priority, id) order. When several rules match the same transaction, the higher-priority rule
wins (it is applied last).
priorityis editable via PATCH. - Manually categorized transactions (
category_manual_override: true) are never touched by rules. - A rules pass preserves old categorizations. It only overwrites transactions the current ruleset matches — deleting or changing a rule does not uncategorize transactions it previously matched. This mirrors the dashboard and is intentional. To truly reset, bulk-uncategorize and re-run.
GET /categorizer/rules
Lists all your rules — metadata only (id, name, type, priority,
category_ids[]), no logic.
GET /categorizer/rules/{id}
One rule by id, including its full logic.
POST /categorizer/rules
There are two kinds. Simple rules cover the common case — "if the description contains X, file it under Y" (case-insensitive, 3–128 characters):
curl -X POST https://fintable.io/api/v2/categorizer/rules \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "simple", "text": "STARBUCKS", "category_id": "dining-out_aB3xY9k2Lm"}'
Advanced rules are raw JSONLogic — arbitrary conditions over the amount, dates, account,
description, and even the provider's raw fields (see the JSONLogic reference
below). Note that
logic is a JSON-encoded string, not a nested object:
curl -X POST https://fintable.io/api/v2/categorizer/rules \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "advanced",
"name": "Big grocery runs",
"logic": "{\"if\": [{\"and\": [{\"in\": [\"WHOLE FOODS\", {\"var\": \"transaction.description\"}]}, {\"<\": [{\"var\": \"transaction.amount\"}, -100]}]}, \"groceries_x7Pq2Rv9Zn\", null]}"
}'
Creating or updating a rule returns 202 with the rule object (as above) plus a top-level "application": "queued".
The 202 is telling you something real: the rule is saved, and a full ordered pass of all your rules over allyour
transactions has been queued, along with a coalesced export to your spreadsheets. Poll
GET /categorizer/status to watch it land. Many rapid edits produce one pass and one export —
not one per edit.
PATCH /categorizer/rules/{id}
Updates name, priority, and/or logic. Behavioral changes (logic or priority)
return 202 and queue a rules pass, exactly like creating; a pure rename returns 200 with "application": "none".
DELETE /categorizer/rules/{id}
Deletes the rule. Transactions it previously categorized keep their categories (see the behavior notes above).
Writing advanced rules in JSONLogic
logic must be a JSON object whose single top-level operator is if. Each result branch must be a literal category
id string or literal null — never a computed expression. Your logic is evaluated against this input for every
transaction:
{
"transaction": {
"fin_id": "tx_01JB2M9QK4R7X3W8N5PDY6TF2H",
"ext_id": "plaid-tx-4821bd0e",
"account_id": "acc_01J9V5R9WQD3M8Y2KXN4T7PB6C",
"date": "2026-07-24",
"auth_date": "2026-07-23",
"amount": "-4.50",
"currency": "USD",
"description": "BLUE BOTTLE COFFEE",
"payee": "Blue Bottle Coffee",
"sub_account": null,
"acc_name": "Chase Total Checking",
"raw": {
"provider fields": "..."
}
}
}
Two conveniences to notice: description is uppercased for you (so substring matching is effectively case-insensitive),
and amount is the usual decimal string.
A worked example — "card transactions over $100 at Whole Foods are Groceries"
(remember negative = money out, so over $100 spent means < -100):
{
"if": [
{
"and": [
{
"in": [
"WHOLE FOODS",
{
"var": "transaction.description"
}
]
},
{
"<": [
{
"var": "transaction.amount"
},
-100
]
}
]
},
"groceries_x7Pq2Rv9Zn",
null
]
}
Allowed operators: var, missing, missing_some, if, ==, ===, !=, !==,
!, !!, or, and, >, >=, <, <=, max, min, +, -, *, /, %,
map, reduce, filter, all, none, some, merge, in, cat, substr. (log is not allowed.)
Per-rule limits: 16 KB, nesting depth 20, and a complexity budget of 100 operator nodes — the array operators (map,
filter, reduce, all, none, some) count 10× and may not nest inside each other. There is also an aggregate
budget across all your rules; if you hit it, simplify or delete rules.
POST /categorizer/sync
Queues a full rules pass plus a spreadsheet export (202,
{"data": {"application": "queued"}}). Rule edits queue passes automatically, so you rarely need this — it exists for
"just re-run everything now".
GET /categorizer/status
The honest view of the pipeline:
curl https://fintable.io/api/v2/categorizer/status \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": {
"requested_version": 12,
"completed_version": 12,
"active_version": null,
"settled": true,
"failed_at": null,
"destinations": [
{
"destination": "airtable",
"requested_version": 12,
"completed_version": 12,
"failed_at": null
},
{
"destination": "gsheet:184",
"requested_version": 12,
"completed_version": 12,
"failed_at": null
}
]
}
}
Every requested change bumps requested_version; passes and exports chase it.
active_version is the pass executing right now — null whenever nothing is running.
settled: true means everything you've asked for has fully landed — in the database and in every spreadsheet
destination. To wait for a rule change to take effect, poll until settled is true.
Integration
| Endpoint | What it does |
|---|---|
GET /integrations |
Status and health of your spreadsheet integrations |
An Integration is a destination Fintable syncs into — your Airtable base or Google Sheets spreadsheets. It's the bridge
between this API and the spreadsheet world: grab
base_id or spreadsheet_id here, then work with Airtable's or Google's own APIs directly against the synced data.
GET /integrations
curl https://fintable.io/api/v2/integrations \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": {
"airtable": {
"base_id": "appXk2fW9qLmN3vT8",
"url": "https://airtable.com/appXk2fW9qLmN3vT8",
"accounts_table_name": "Accounts",
"transactions_table_name": "Transactions",
"holdings_table_name": "Holdings",
"transactions_enabled": true,
"token_type": "OAUTH",
"healthy": true,
"error": null
},
"google_sheets": [
{
"spreadsheet_id": "1vQx8mP2kL9nR4tY7wZ3jB6cD5eF8gH0iJ2kL4mN6oP8",
"url": "https://docs.google.com/spreadsheets/d/1vQx8mP2kL9nR4tY7wZ3jB6cD5eF8gH0iJ2kL4mN6oP8",
"title": "Family finances",
"tabs": {
"accounts": {
"sheet": "Accounts",
"range": "A1:Z"
},
"transactions": {
"sheet": "Transactions",
"range": "A1:Z"
},
"holdings": {
"sheet": null,
"range": null
}
},
"healthy": true,
"error": null
}
]
}
}
The airtable object (null if you haven't connected Airtable):
| Field | Type | Meaning |
|---|---|---|
base_id |
string | The Airtable base Fintable syncs into — use it with Airtable's own API |
url |
string | Direct link to the base |
accounts_table_name |
string | Configured table for accounts |
transactions_table_name |
string | Configured table for transactions |
holdings_table_name |
string | null | Configured table for holdings, when enabled |
transactions_enabled |
boolean | Whether transaction syncing to this base is on |
token_type |
string | OAUTH, PERSONAL, or DEPRECATED |
healthy |
boolean | Whether the last validation passed |
error |
string | null | What's wrong, when healthy is false |
Each entry in google_sheets[]:
| Field | Type | Meaning |
|---|---|---|
spreadsheet_id |
string | The spreadsheet Fintable syncs into — use it with Google's own API |
url |
string | Direct link to the spreadsheet |
title |
string | The spreadsheet's title |
tabs |
object | Configured accounts / transactions / holdings tabs, each {sheet, range} (null when not configured) |
healthy |
boolean | Whether the last validation passed |
error |
string | null | What's wrong, when healthy is false |
Health is served from a validation cache — the first call after a quiet period may take a few seconds while it validates live. Set up integrations at Dashboard → Integrations.
Institution
| Endpoint | What it does |
|---|---|
GET /institutions |
Search the directory (public, offset-paged) |
An Institution is a bank or brokerage Fintable can connect to — an entry in the searchable directory of ~50,000. The
directory is public — no authentication — so you can use it in signup flows or availability checks. Its slugvalues
feed
POST /connections/link.
{
"data": [
{
"slug": "12913262_chase",
"name": "Chase",
"domain": "chase.com",
"supported": true,
"countries": [
"US"
],
"coverage_url": "https://fintable.io/coverage/us/12913262_chase",
"updated_at": "2026-07-19T02:11:36Z"
}
],
"meta": {
"page": 1,
"has_more": true
}
}
| Field | Type | Meaning |
|---|---|---|
slug |
string | The institution's id — pass it to POST /connections/link |
name |
string | Display name |
domain |
string | null | The institution's website domain |
supported |
boolean | Whether Fintable can connect to it right now |
countries |
array of strings | ISO country codes it operates in |
coverage_url |
string | null | Public coverage page; null when none exists |
updated_at |
string | null | When the directory entry last changed |
GET /institutions
Query parameters:
| Param | Meaning |
|---|---|
q |
Fuzzy name search, min 3 characters |
domain |
Match by website domain |
country |
ISO country code, e.g. US |
provider |
PLAID, NORDIGEN, AKOYA, FINICITY, MERCURY, SNAPTRADE (GoCardless is NORDIGEN internally) |
page |
Page number — always 10 results per page |
curl "https://fintable.io/api/v2/institutions?q=chase&country=US"
This is the API's one offset-paged endpoint — page through with page until
has_more is false.
Docs and guide as data
| Endpoint | What it returns |
|---|---|
GET /guide |
The full Fintable user guide as markdown |
GET /docs |
This document as markdown |
GET /openapi.json |
The OpenAPI 3.1 description of this API |
The documentation itself is available as plain markdown — handy for feeding to an LLM or rendering in your own tools. All public, no authentication.
GET /guide
The full Fintable user guide as markdown. ?locale=main|uk|es selects the language.
GET /docs
This document as markdown. ?locale=main|uk|es selects the language.
GET /openapi.json
The OpenAPI 3.1 description of this API — point Swagger UI, Postman, or a code generator at it.
API Conventions
A few conventions hold everywhere, so you only need to learn them once.
The response envelope
Lists return {"data": [...]} and single objects return {"data": {...}}. Transaction lists additionally carry a
next_cursor (see
Pagination). The one exception: the public institutions directory is offset-paged and uses
meta: {page, has_more}.
Requests should send Accept: application/json.
Money is a string
Amounts are exact decimal strings — "-4.50", "1234.56" — never floats, so you never lose a cent to
floating-point rounding. Negative amounts are money out. Every amount travels with a separate currency field (the
effective currency, honoring any currency override you've set). Account balances follow the same convention.
Timestamps and dates
Timestamps are ISO-8601 UTC, like 2026-07-26T15:04:05Z. Transaction date and
auth_date are plain YYYY-MM-DD strings.
Object IDs are opaque
IDs are opaque strings up to 64 characters. Store them as-is and don't parse meaning out of them — the shapes below are for recognition, not for parsing:
| Object | What it looks like |
|---|---|
| Transaction | tx_01J0AB... (long-standing accounts may have legacy numeric strings like "48214321") |
| Account | acc_01J0AB... (legacy numeric strings exist here too) |
| Holding | hol_01J0AB... or a legacy numeric string |
| Category | {name-slug}_{10 alphanumerics}, e.g. dining-out_aB3xY9k2Lm |
| Rule | UUID, e.g. a25a374d-e4d7-4652-aca7-5dd3c3d02d15 |
| Connection | conn_{provider}_{number}, e.g. conn_plaid_1771845993762884095 |
Errors
Every non-2xx response has exactly one shape, so one error handler covers the whole API:
{
"error": {
"type": "not_found",
"message": "No transaction with that id."
}
}
Validation failures (422) additionally include per-field messages:
{
"error": {
"type": "validation_failed",
"message": "The given data was invalid.",
"errors": {
"sync_start_date": [
"Trial accounts can sync at most 30 days of history."
]
}
}
}
| HTTP | type |
When you'll see it |
|---|---|---|
| 400 | bad_request / invalid_cursor |
Malformed request; or a cursor replayed against a different sort order |
| 401 | unauthenticated |
Missing, expired, or revoked token |
| 403 | forbidden |
Valid token, but the action isn't allowed (e.g. syncing on a free account) |
| 404 | not_found |
No such object — including objects that belong to another account |
| 405 | method_not_allowed |
Wrong HTTP verb |
| 409 | conflict |
The action conflicts with current state (e.g. deleting a category rules still use) |
| 413 | payload_too_large |
Request body over the limit |
| 422 | validation_failed |
The request was understood but a field is invalid |
| 429 | rate_limited |
Slow down — comes with a Retry-After header |
| 500 | server_error |
Our fault; try again or contact us |
| 503 | service_unavailable |
Temporary outage or maintenance |
Rate limits
The limits are generous for polite, well-behaved clients; you'll only meet them if you hammer an endpoint. Every route
has exactly one bucket, and MCP tool calls consume the same buckets as their REST counterparts. When you hit a limit
you get a 429 with a
Retry-After header — honor it.
| Bucket | Limit |
|---|---|
| Authenticated reads | 300/min per token |
| Generic writes (PATCH/DELETE, categories) | 60/min per account |
| Rule create/update | 12/hour per account |
POST /sync (and per-connection) |
Personal/Trial: 2/day · Office/Enterprise: 1/hour |
POST /connections/link (and reconnect) |
1/min per account |
PATCH /transactions/bulk |
10/hour per account |
POST /categorizer/sync |
6/day per account |
Public GET /institutions |
60/min per IP |
Public /guide, /docs, openapi.json |
60/min per IP |
| MCP endpoint | 120/min per token |
POST /oauth/register |
5/hour per IP |
Caching
Authenticated responses are always served fresh (Cache-Control: no-store). Public endpoints (/institutions,/guide,
/docs) are cacheable for up to an hour.
Pagination
Years of transaction history can run to tens of thousands of rows, so
GET /transactions (and the per-account variant) never returns everything at once — it paginates with an opaque
cursor. Why a cursor and not page numbers? Because your data moves: a sync can insert or update transactions while
you're paging, and offset-based pages would silently skip or duplicate rows. A cursor pins your exact position in the
sequence, so a full walk sees every row exactly once.
Ask for a page, and if there's more, the response tells you where to continue:
curl "https://fintable.io/api/v2/transactions?limit=100" \
-H "Authorization: Bearer YOUR_TOKEN"
{
"data": [
"... 100 transactions ..."
],
"next_cursor": "eyJ2IjoxLCJvIjoiZGF0ZSIsazoi..."
}
Pass the cursor back to get the next page, and keep going until next_cursor is
null:
curl "https://fintable.io/api/v2/transactions?cursor=eyJ2IjoxLC..." \
-H "Authorization: Bearer YOUR_TOKEN"
The rules:
limitdefaults to 100 and maxes out at 500.- Cursors are opaque and tied to their sort order. A cursor minted from an
order=datelisting is rejected (400invalid_cursor) if replayed againstorder=updated, and vice versa. - The default order is newest-first by transaction date.
Incremental Sync
If you're mirroring transactions into your own database or app, re-downloading your entire history just to pick up
yesterday's changes is slow and wasteful — and the rate limits are not sized for it. Incremental sync is the efficient
alternative: every transaction carries an updated_at timestamp, and the list endpoint can order by it, so you can ask
for exactly "everything that changed since I last looked".
Polling for changes
Poll with ?order=updated&updated_since=<ISO timestamp>. Results come back ordered by
updated_at ascending, paginated with the same cursor mechanism as above. The recipe:
- Call
GET /transactions?order=updated&updated_since=2026-07-25T00:00:00Z. - Page through with
next_cursor, processing each transaction. - Remember the highest
updated_atyou processed; use it as the nextupdated_since.
The honest fine print: deletions
Deletions are invisible to incremental polling — there is no tombstone or deletion log. Two situations to design for:
We're working on a better solution that makes deletions visible. Until then, our best advice is: do not download
pending transactions. Request pending=false when mirroring transactions into your own database or app.
- Pending-transaction churn. Pending transactions can be replaced when they post (new id, adjusted amount or date). If you do import them, re-fetch a trailing 30-day window periodically to catch this.
- Whole-account deletions. When an account disappears from
GET /accountsor flips toenabled: false, drop all transactions you cached for that account.
If you need certainty beyond that, periodically re-fetch in full.
Questions or Feedback?
If something here is unclear, missing, or just wrong, we'd like to know — contact us or use the support bubble on any page. If you're building something on the API, we're happy to help you get it working.