Redirect to fintable.io →

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: the institution directory, currency exchange rates from the European Central Bank, stock prices, and the docs-as-data endpoints. None of these need authentication.

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, Codex)

mcp:use is a superset: it is accepted everywhere read or write is. Plain read/write tokens are rejected by the MCP endpoint.


Workspaces

A Workspace is one isolated set of Fintable connections, accounts, transactions, categorizer rules, and integrations. Every account has a default workspace — the credential owner's own Workspace, used whenever no Workspace is selected. Office and Enterprise plans can add managed Workspaces for clients, companies, or households under a primary workspace that owns the billing. Workspaces are how you securely share financial data with a team, family member, or client: each Workspace has its own login and its own explicitly granted API access, scoped to that Workspace alone.

Scopes and Workspace grants answer different questions: scopes are what a credential may do (read/write), Workspace grants are where it may do it. They compose — a read-only token granted three Workspaces can read all three and write to none.

Granting Workspace access

Access to a managed Workspace is explicit, per credential:

  • Personal Access Tokens — tick the Workspaces the token may reach when creating it on Dashboard → API, or edit an existing token's Workspace access there later (no secret rotation needed).
  • OAuth / MCP apps — the consent screen lists your eligible Workspaces; tick the ones the app may access. The grant follows the app, so it survives token refresh. Edit or revoke it any time from Connected Apps.

Nothing is granted silently: existing credentials stay default-Workspace-only until you edit their access or reconnect them with new selections, and newly created Workspaces are never added to an existing credential automatically. Grants are re-checked on every request — revocation, plan downgrade, or deleting the Workspace takes effect immediately.

GET /workspaces

Lists the Workspaces the calling credential may select: its default Workspace first, then granted managed Workspaces by name. This endpoint itself accepts no workspace_id.

curl https://fintable.io/api/v2/workspaces \
  -H "Authorization: Bearer YOUR_TOKEN"
{
    "data": [
        {
            "id": "ws_01K2F3A6QH2D5J7M9V4X8N1BRC",
            "name": "Nate",
            "kind": "primary",
            "is_default": true
        },
        {
            "id": "ws_01K2F3C41W8B6Z0H7R9T5Y2QPM",
            "name": "Client A",
            "kind": "managed",
            "is_default": false
        }
    ]
}
Field Type Meaning
id string Opaque public Workspace ID — the value you pass as workspace_id
name string The Workspace's display name
kind string Billing relationship: primary owns the plan; managed is covered by its primary's plan
is_default boolean true for the Workspace used when workspace_id is omitted

Selecting a Workspace

Every workspace-bound endpoint accepts an optional workspace_id query parameter — on every HTTP method. It is always a query parameter: request bodies stay resource data only, and a workspace_id field in a JSON body never selects a Workspace.

# Read a client's transactions
curl "https://fintable.io/api/v2/transactions?workspace_id=ws_01K2F3C41W8B6Z0H7R9T5Y2QPM&limit=100" \
  -H "Authorization: Bearer YOUR_TOKEN"

# Rename an account inside that Workspace (selector in the query, body is resource data)
curl -X PATCH "https://fintable.io/api/v2/accounts/1234?workspace_id=ws_01K2F3C41W8B6Z0H7R9T5Y2QPM" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"display_name": "Client A Checking"}'

# Start a sync there
curl -X POST "https://fintable.io/api/v2/sync?workspace_id=ws_01K2F3C41W8B6Z0H7R9T5Y2QPM" \
  -H "Authorization: Bearer YOUR_TOKEN"

# Mint a connection link that creates the bank inside that Workspace
curl -X POST "https://fintable.io/api/v2/connections/link?workspace_id=ws_01K2F3C41W8B6Z0H7R9T5Y2QPM" \
  -H "Authorization: Bearer YOUR_TOKEN"

The selector applies to /connections, /accounts, /transactions, /sync, /integrations, and every /categorizer/* endpoint, including their member and link routes. It does not apply to:

  • /me — always describes the authenticated credential owner and their billing state; workspace_id never changes it
  • /workspaces — defines the valid selector values
  • the public endpoints (/guide, /docs, /openapi.json, /institutions, /rates, /prices)
  • /feedback — contacts support rather than accessing Workspace data

Behavior details:

  • Omitting workspace_id preserves today's behavior exactly — the credential operates on its own default Workspace. Passing your own Workspace's ID is equivalent to omitting it.
  • Successful workspace-bound responses add a top-level workspace_id beside data/next_cursor, so logs and clients can always tell which Workspace answered: {"data": [...], "workspace_id": "ws_...", "next_cursor": null}.
  • An unknown, malformed, deleted, or ungranted workspace_id returns the standard not_found error envelope — unknown and unauthorized are deliberately indistinguishable.
  • Transaction pagination cursors are bound to the Workspace they were issued for; replaying a cursor against another Workspace fails with invalid_cursor. Request each page with the same workspace_id.
  • Rate limits belong to the credential/owner, not the selected Workspace — selecting several Workspaces never multiplies a quota.
  • Connection links minted with a workspace_id create or reconnect banks inside that Workspace. The browser flow asks for your (the credential owner's) password, then continues in the target Workspace.

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; volume is max(abs(total inflows), abs(total outflows)) across all enabled bank accounts in all Workspaces under the primary Workspace; 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 provider behind this connection, e.g. PLAID, NORDIGEN, AKOYA, FINICITY, MERCURY, PRIVATBANK-BIZ, MONOBANK, SNAPTRADE
institution_name string The bank's name (your custom name, when set)
name string | null Your custom name for the connection
healthy boolean false when the provider reports a problem or the latest sync failed
status_text string Human-readable status: OK, Last sync failed., 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",
        "ext_id": "Qg39dj7M9vIqwQrybDRni1oMKAAvx8s4vO4yO",
        "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_...
ext_id string The provider's own id — the **Plaid Account ID column in your spreadsheets. See matching spreadsheet rows
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",
        "ext_id": "3k3OMr7qdPtD8oXPPBNZtarZeDDPwwfoPX0ED",
        "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_...
ext_id string The provider's own id — the **Plaid TX ID column in your spreadsheets. See matching spreadsheet rows
account_id string The Account this transaction belongs to — its id, not the **Plaid Account ID spreadsheet column
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.
  • Your rules are the only thing that categorizes. Nothing is categorized at sync time, and Fintable never assigns a category from a provider's own merchant enrichment — so this works identically on Plaid, GoCardless, Akoya, and the rest. A transaction with category: null means no rule matched it; that is the entire explanation. POST /categorizer/sync re-runs your current rules across all history, but it cannot invent a category your rules don't assign. To see how far your rules currently reach, ask GET /categorizer/status?include=coverage.

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": "3k3OMr7qdPtD8oXPPBNZtarZeDDPwwfoPX0ED",
        "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. fin_id and ext_id are the transaction's id and ext_id from the API.

One caveat worth designing around: payee is only as good as the data the bank sends. Plenty of connections — many open-banking ones in particular — send no merchant name at all, and payee is null on every one of those rows. A rule that must work everywhere should key on description, and reach into raw for provider-specific fields when it needs more.

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.

Add ?include=coverage for how much of your history your rules actually reach:

{
    "data": {
        "settled": true,
        "coverage": {
            "total": 4527,
            "categorized": 2627,
            "uncategorized": 1900,
            "manual_override": 12
        },
        "...": "..."
    }
}

total counts every transaction the categorizer considers, categorized those carrying a category, and manual_override those you set by hand — rules never touch that last group. A large uncategorized is not a bug and not a provider limitation: it is the number of transactions no rule of yours matches yet (see how rules behave). Coverage is opt-in because computing it scans your whole transaction set — don't ask for it inside a settled polling loop.

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, TABS, SNAPTRADE (GoCardless is NORDIGEN; direct bank APIs are TABS)
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.

Exchange rate

Endpoint What it does
GET /rates Exchange rates for one day (and conversion)
GET /rates/timeseries A daily series of rates between two dates
GET /rates/currencies The currency codes on offer

Currency exchange rates, straight from the European Central Bank. Like the institutions directory these are public — no authentication — because they are public-domain reference data. Handy for reporting a multi-currency account set in one currency, or for valuing an old transaction at the rate that actually applied on its date.

Two things about ECB data worth knowing before you build on it:

  • The ECB publishes once per working day, around 16:00 CET. There is no intraday movement, so polling more than hourly gains you nothing.
  • There is no data at all for weekends and holidays. Ask for a Saturday and you get the preceding working day — the date in the response always tells you which day was really used, so trust that field over the one you sent.
{
    "data": {
        "base": "USD",
        "date": "2026-07-27",
        "amount": "1",
        "rates": {
            "EUR": "0.878",
            "GBP": "0.7509"
        }
    }
}
Field Type Meaning
base string The currency the rates are quoted against
date string The working day these rates were published — may be earlier than you asked
amount string How much of base was converted; "1" (the default) gives plain rates
rates object Currency code → value, as exact decimal strings

GET /rates

Query parameters:

Param Meaning
base Three-letter base currency code. Defaults to EUR
symbols Codes to return — USD,GBP, or repeated symbols[]. Omit for every currency published
date A historical date (YYYY-MM-DD), from 1999-01-04 onward. Defaults to the latest publication
amount Amount of base to convert. Defaults to 1
# Today's rates
curl "https://fintable.io/api/v2/rates?base=USD&symbols=EUR,GBP"

# Convert 250 USD into euros
curl "https://fintable.io/api/v2/rates?base=USD&symbols=EUR&amount=250"

# The rate that applied on a specific day
curl "https://fintable.io/api/v2/rates?base=USD&symbols=EUR&date=2024-01-15"

With amount set, rates holds that amount converted rather than the raw rate — which is why amount is echoed back in the response.

GET /rates/timeseries

A rate per working day across a range. Days the ECB skipped are simply absent from rates — expect gaps, don't treat them as errors.

Param Meaning
start First date (YYYY-MM-DD) — required
end Last date. Defaults to the latest publication
base Base currency code. Defaults to EUR
symbols Codes to return
amount Amount of base to convert. Defaults to 1
curl "https://fintable.io/api/v2/rates/timeseries?start=2024-01-15&end=2024-01-18&base=USD&symbols=EUR"
{
    "data": {
        "base": "USD",
        "start_date": "2024-01-15",
        "end_date": "2024-01-18",
        "amount": "1",
        "rates": {
            "2024-01-15": { "EUR": "0.91366" },
            "2024-01-16": { "EUR": "0.91895" },
            "2024-01-17": { "EUR": "0.91937" },
            "2024-01-18": { "EUR": "0.91954" }
        }
    }
}

At most 366 days per call; longer ranges return a 422 asking you to narrow it.

GET /rates/currencies

Every currency code the ECB publishes, with its name — roughly 30 of them.

curl "https://fintable.io/api/v2/rates/currencies"
{
    "data": [
        { "code": "AUD", "name": "Australian Dollar" },
        { "code": "BRL", "name": "Brazilian Real" }
    ]
}

A code that isn't on this list (or that the ECB had stopped publishing on the date you asked for — HRK after Croatia joined the euro, say) comes back as a 422, not a 404.

Stock price

Endpoint What it does
GET /prices Latest prices for up to 50 tickers
GET /prices/{symbol} Latest price for one ticker
GET /prices/{symbol}/history Historical OHLCV bars

Prices for US-listed equities, also public — no authentication. Pair them with your investment holdings to value a portfolio, or with GET /rates to express those values in your own currency.

{
    "data": [
        {
            "symbol": "AAPL",
            "price": "183.63",
            "currency": "USD",
            "open": "182.16",
            "high": "184.26",
            "low": "180.93",
            "previous_close": "185.92",
            "change": "-2.29",
            "change_percent": "-1.2317",
            "volume": 65603010,
            "trading_day": "2024-01-16",
            "as_of": "2024-01-16T20:59:59Z",
            "feed": "iex"
        }
    ]
}
Field Type Meaning
symbol string The ticker, always uppercase
price string Last traded price, falling back to the day's close
currency string The exchange's currency — USD for US equities
open high low string | null The current trading day's range
previous_close string | null Previous session's close
change string | null priceprevious_close
change_percent string | null The same move as a percentage
volume integer | null Shares traded so far today
trading_day string | null The session the day figures belong to
as_of string | null When the price was observed
feed string The market data feed behind the quote

Prices are exact decimal strings, like every other number in this API.

The feed field is worth reading. On iex — the default — figures come from the IEX exchange alone rather than the consolidated tape, so volume is IEX volume, a small fraction of the symbol's true traded total, and price can differ slightly from the last consolidated sale. Fine for valuing a position; don't treat it as an official quote. Thinly traded symbols may not print on IEX at all on a given day, in which case price falls back to the day's close.

GET /prices

Param Meaning
symbols Comma-separated tickers — required, at most 50 per call
curl "https://fintable.io/api/v2/prices?symbols=AAPL,MSFT,NVDA"

Results come back in the order you asked for them. Tickers with no price data are left out of the response rather than failing the whole request — so check what came back rather than assuming the list matches what you sent. The single-ticker route below is the opposite: it 404s.

GET /prices/{symbol}

One ticker, same object but unwrapped from the array. Returns 404 when there's no price data for it.

curl "https://fintable.io/api/v2/prices/AAPL"

GET /prices/{symbol}/history

Historical bars, adjusted for splits and dividends — so a multi-year window is directly comparable end to end.

Param Meaning
timeframe 1min, 5min, 15min, 1hour, 1day, 1week, 1month. Defaults to 1day
start First date (YYYY-MM-DD)
end Last date (YYYY-MM-DD)
limit Maximum bars, 1–1000. Defaults to 1000
curl "https://fintable.io/api/v2/prices/AAPL/history?timeframe=1day&start=2024-01-02&end=2024-01-31"
{
    "data": {
        "symbol": "AAPL",
        "timeframe": "1day",
        "currency": "USD",
        "feed": "iex",
        "bars": [
            {
                "timestamp": "2024-01-02T05:00:00Z",
                "date": "2024-01-02",
                "open": "187.15",
                "high": "188.44",
                "low": "183.89",
                "close": "185.64",
                "volume": 1795262,
                "trade_count": 18557,
                "vwap": "185.9"
            }
        ]
    }
}

A ticker or range with no bars returns 404.

Feedback

Endpoint What it does
POST /feedback Email the Fintable team and get a human reply

Something broken, missing, or confusing? Tell us from wherever you are — your code, your terminal, or your AI assistant — without leaving it to open a support page. The message lands in the same inbox a human support team works through all day, with your address as Reply-To, and you get an answer within 2 business days, usually much sooner.

POST /feedback

Field Type Meaning
message string Required. The report itself, 10–10000 characters. Detail is what makes a reply useful — see below
subject string A one-line summary for the inbox
email string Where to reply. Defaults to your account's email address
screenshot string An image as base64 (a data: URL is fine): PNG, JPEG, WebP, or GIF, up to 2 MB decoded
screenshot_filename string A name for the attachment, e.g. sync-error.png

Include as much as you can — what you were doing, what happened, what you expected instead, the exact error text, the connection or account ids involved, and when it happened. A screenshot of what you're seeing is worth several paragraphs. Vague reports cost you a round-trip.

curl -X POST https://fintable.io/api/v2/feedback \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "subject": "Holdings missing for my brokerage account",
        "message": "acct_1182 has synced fine since May, but GET /accounts/acct_1182/holdings has returned an empty list since 2026-07-28. The connection reports status ok and a sync at 2026-08-03T06:12:00Z.",
        "screenshot": "iVBORw0KGgoAAAANSUhEUg..."
      }'
{
    "data": {
        "sent": true,
        "reply_to": "[email protected]",
        "screenshot_attached": true,
        "message": "Thanks — your message is with the Fintable team..."
    }
}

A 2xx means the email really was sent, not merely queued. Any token works, including a read-only one. The limit is 2 per hour and 5 per day, and only messages we actually send count against it — a rejected request costs you nothing.

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

Matching API objects to your spreadsheet rows

If Fintable already syncs into an Airtable base or a Google Sheet, sooner or later you will want to line those rows up against the API — once, to adopt the API on an existing base, or continuously.

Do not match on date + amount + description. It works for most rows and then fails silently on exactly the rows you care about: two identical salary transfers in the same week are indistinguishable that way.

Match on ext_id. It is the value Fintable writes into the key column of every synced destination:

Spreadsheet column API field Notes
**Plaid TX ID ext_id on Transaction Unique within its account
**Plaid Account ID ext_id on Account The provider's identifier for the account

The Plaid in those column names is legacy. They were named when Plaid was the only provider and they keep the same name on GoCardless, Akoya, SnapTrade, and everything else. The value is not a Plaid id — it is whatever the provider behind that connection calls the object.

ext_id is not id. id (tx_..., acc_...) is Fintable's own identifier and it is what every endpoint here takes and returns; ext_id exists for this join and nothing else. Treat it as opaque too — its shape varies by provider (a Plaid id like 3k3OMr7qdPtD8oXPPBNZtarZeDDPwwfoPX0ED, a composite like 7863464615930617517--a7a655b9c42ea4f4ad246543c9e9f044 on a GoCardless connection) and those shapes are not a contract. Compare it, do not parse it.

The account join is where this bites. **Plaid Account ID is not the API's account_id. A transaction's account_id is Fintable's id for the account — the same value as id on Account — while the spreadsheet column carries the provider's id, which is that account's ext_id. They are two different values for the same account, so joining your Accounts table on account_id silently matches nothing. Join it on ext_id; GET /accounts returns both, so one pass over it gives you the translation table.

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
POST /feedback 2/hour and 5/day per account (sent messages only)
Public GET /institutions 60/min per IP
Public GET /rates (and /rates/*) 60/min per IP
Public GET /prices (and /prices/*) 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, /rates, /prices) are cacheable for up to an hour. That includes stock prices, so a quote can trail the market by up to an hour — every price carries an as_of timestamp saying when it was actually observed. Plenty for valuing a portfolio; not intended for trading.


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 — send it straight from your code with POST /feedback (or ask your AI assistant to use the feedback MCP tool), or 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.

Language
Currency
Number Format