API Docs

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; null clears it.
  • sync_start_dateYYYY-MM-DD; null clears 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": false permanently 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), or
  • filters — 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, or description (pending and amount_* 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). priority is 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:

  • limit defaults to 100 and maxes out at 500.
  • Cursors are opaque and tied to their sort order. A cursor minted from an order=date listing is rejected (400 invalid_cursor) if replayed against order=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:

  1. Call GET /transactions?order=updated&updated_since=2026-07-25T00:00:00Z.
  2. Page through with next_cursor, processing each transaction.
  3. Remember the highest updated_at you processed; use it as the next updated_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.

  1. 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.
  2. Whole-account deletions. When an account disappears from GET /accounts or flips to enabled: 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.

Display Preferences
Language

We switched you to your saved language. Pick another and press Done to switch back.

Currency
Number Format