# Fintable > Fintable syncs bank accounts to Airtable and Google Sheets, with a first-party REST > API and an MCP server so AI assistants can read and manage the data with the user's > permission. This file contains the complete API documentation and product user guide. You are working with the user's Fintable account. Two ways in, both first-party and user-authorized: 1. **MCP server (preferred for assistants):** connect to `https://fintable.io/mcp`. OAuth is discovered automatically (`/.well-known/oauth-authorization-server`); the user logs in and approves access. Tools mirror the REST API one-to-one. 2. **REST API:** base URL `https://fintable.io/api/v2`, bearer auth. The user creates a Personal Access Token at Dashboard → API, or your app completes OAuth Authorization Code + PKCE against `https://fintable.io/oauth/authorize` / `/oauth/token` (register a client at `/oauth/register`). Ground rules: amounts are exact decimal strings (negative = money out); ids are opaque; transactions paginate with `next_cursor`; `bulk_categorize` defaults to dry-run; disabling an account DELETES its transactions; deleting a connection purges its data. The complete API reference follows, then the product user guide. --- 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`](https://fintable.io/api/v2) | | **OpenAPI 3.1 description** | [`https://fintable.io/api/v2/openapi.json`](https://fintable.io/api/v2/openapi.json) | | **MCP server for AI assistants** | [`https://fintable.io/mcp`](https://fintable.io/mcp) | | **AI Skill or LLM docs** ([llms.txt](https://llmstxt.org)) | [`https://fintable.io/llms.txt`](https://fintable.io/llms.txt) | | **Manage tokens** | [Dashboard → API](https://fintable.io/dash/v2/api) | ## 1. Create a Fintable [Sign up here](https://fintable.io/register) — 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**](https://fintable.io/dash/v2/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: ```bash curl -X POST https://fintable.io/api/v2/connections/link \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```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`](#post-connectionslink). ## 4. Retrieve your balances and transactions Once the first sync lands — usually within a couple of minutes — your data is there. Balances: ```bash curl https://fintable.io/api/v2/accounts \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "data": [ { "id": "acc_01J9V5R9WQD3M8Y2KXN4T7PB6C", "name": "Chase Total Checking", "balance": "5240.12", "balance_available": "5190.12", "currency": "USD", "...": "..." } ] } ``` And transactions: ```bash curl "https://fintable.io/api/v2/transactions?limit=5" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "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](#account) and [Transaction](#transaction) — and the whole [API Reference](#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`](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**](https://fintable.io/dash/v2/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](#api-conventions) and [Pagination](#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`](#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. ```json { "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: ```bash curl https://fintable.io/api/v2/me \ -H "Authorization: Bearer YOUR_TOKEN" ``` ## Connection | Endpoint | What it does | |----------------------------------------------------------|--------------------------------------------| | [`GET /connections`](#get-connections) | List all connections | | [`GET /connections/{id}`](#get-connectionsid) | One connection | | [`PATCH /connections/{id}`](#patch-connectionsid) | Rename or set the sync start date | | [`DELETE /connections/{id}`](#delete-connectionsid) | Disconnect the bank and purge its data | | [`POST /connections/link`](#post-connectionslink) | Mint a browser link to connect a new bank | | [`POST /connections/{id}/link`](#post-connectionsidlink) | 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. ```json { "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](#sync) 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_date` — `YYYY-MM-DD`; `null` clears it. Fintable will only sync transactions from this date forward. ```bash 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**: ```json { "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): ```bash curl -X POST https://fintable.io/api/v2/connections/link \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"institution": "12913262_chase"}' ``` ```json { "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](#institution), 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`](#post-connectionslink) but mints a **reconnect** link for an existing connection, and is exempt from the new-connection checks. ## Account | Endpoint | What it does | |---------------------------------------------|------------------------------------------------------------| | [`GET /accounts`](#get-accounts) | List all accounts, including disabled ones | | [`GET /accounts/{id}`](#get-accountsid) | One account | | [`PATCH /accounts/{id}`](#patch-accountsid) | 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. ```json { "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](#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`](#get-accountsidholdings) | 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`. ```json { "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`](#get-transactions) | All transactions, [cursor-paged](#pagination) | | [`GET /accounts/{id}/transactions`](#get-accountsidtransactions) | One account's transactions | | [`GET /transactions/{id}`](#get-transactionsid) | One transaction | | [`PATCH /transactions/{id}`](#patch-transactionsid) | Set or clear the category | | [`PATCH /transactions/bulk`](#patch-transactionsbulk) | 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. ```json { "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](#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](#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](#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](#pagination), 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](#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). ### GET /transactions/{id} One transaction by id. `?include=raw` works here too. ### PATCH /transactions/{id} Does exactly one thing — sets the category: ```bash 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: ```bash 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 }' ``` ```json { "data": { "dry_run": true, "matched_count": 37, "sample": [ "... up to 10 matching transactions ..." ] } } ``` Happy with the match? Send the same request without `dry_run`: ```json { "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`](#get-sync) | Schedule, live sync jobs, and per-connection status | | [`POST /sync`](#post-sync) | Sync all connections now | | [`POST /sync/{connection_id}`](#post-syncconnectionid) | 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](#connection) and throughout `GET /sync`: ```json { "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}` | ```bash curl https://fintable.io/api/v2/sync \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "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: ```json { "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`](#get-sync) or the `sync_status` on each connection. ### POST /sync/{connection_id} Syncs just one connection — same response shape as [`POST /sync`](#post-sync) (one element), same entitlement requirement. ## Category | Endpoint | What it does | |--------------------------------------------------------------------------|----------------------------| | [`GET /categorizer/categories`](#get-categorizercategories) | List categories | | [`GET /categorizer/categories/{id}`](#get-categorizercategoriesid) | One category | | [`POST /categorizer/categories`](#post-categorizercategories) | Create a category (201) | | [`PATCH /categorizer/categories/{id}`](#patch-categorizercategoriesid) | Rename, re-header, recolor | | [`DELETE /categorizer/categories/{id}`](#delete-categorizercategoriesid) | 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](#rule) apply them automatically as transactions sync in. Everything you can do on the [categorizer dashboard](https://fintable.io/dash/v2/categorizer) you can do here. Limit: 1,000 categories per account. ```json { "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`: ```bash 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: ```json { "data": { "deleted": true, "uncategorized_count": 118 } } ``` ## Rule | Endpoint | What it does | |----------------------------------------------------------------|----------------------------------------------------| | [`GET /categorizer/rules`](#get-categorizerrules) | List rules — metadata only, no `logic` | | [`GET /categorizer/rules/{id}`](#get-categorizerrulesid) | One rule, including its full `logic` | | [`POST /categorizer/rules`](#post-categorizerrules) | Create a rule (202) | | [`PATCH /categorizer/rules/{id}`](#patch-categorizerrulesid) | Update `name`, `priority`, and/or `logic` | | [`DELETE /categorizer/rules/{id}`](#delete-categorizerrulesid) | Delete a rule | | [`POST /categorizer/sync`](#post-categorizersync) | Queue a full rules pass + spreadsheet export (202) | | [`GET /categorizer/status`](#get-categorizerstatus) | 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. ```json { "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](#category) 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): ```bash 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](https://jsonlogic.com) — arbitrary conditions over the amount, dates, account, description, and even the provider's raw fields (see the [JSONLogic reference](#writing-advanced-rules-in-jsonlogic) below). Note that `logic` is a JSON-encoded **string**, not a nested object: ```bash 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 **all**your transactions has been queued, along with a coalesced export to your spreadsheets. Poll [`GET /categorizer/status`](#get-categorizerstatus) 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: ```json { "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`): ```json { "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: ```bash curl https://fintable.io/api/v2/categorizer/status \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "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`](#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 ```bash curl https://fintable.io/api/v2/integrations \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "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**](https://fintable.io/dash/v2/integrations). ## Institution | Endpoint | What it does | |------------------------------------------|---------------------------------------------| | [`GET /institutions`](#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 `slug`values feed [`POST /connections/link`](#post-connectionslink). ```json { "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 | ```bash 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`](#get-guide) | The full Fintable user guide as markdown | | [`GET /docs`](#get-docs) | This document as markdown | | [`GET /openapi.json`](#get-openapijson) | 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](#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: ```json { "error": { "type": "not_found", "message": "No transaction with that id." } } ``` Validation failures (422) additionally include per-field messages: ```json { "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: ```bash curl "https://fintable.io/api/v2/transactions?limit=100" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```json { "data": [ "... 100 transactions ..." ], "next_cursor": "eyJ2IjoxLCJvIjoiZGF0ZSIsazoi..." } ``` Pass the cursor back to get the next page, and keep going until `next_cursor` is `null`: ```bash 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=`. 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](https://fintable.io/contact) 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. --- # Fintable User Guide # 🚀 Getting Started ## What is Fintable? Fintable automatically syncs your bank transactions, account balances, and financial data directly into spreadsheets such as Airtable or Google Sheets. It connects to your bank accounts, investment providers, credit cards, and other financial institutions securely and keeps your spreadsheets up to date without manual data entry. It also has a universal banking API powered by Plaid, GoCardless, Akoya, and other bank API aggregators. ## High-Level Overview Getting started with Fintable involves a few simple steps. 1. Sign up at [Fintable.io](https://fintable.io) 2. Connect your bank accounts through our secure connection process 3. Set up your Airtable base or Google Sheets integration 4. Watch your transactions sync automatically every day That's it! We try to do one thing, and do it well: reliably sync your financial data into your spreadsheets. ## ✨ Quickstart with Claude / ChatGPT > You can use an AI Assistant like Claude to setup and fully manage your Fintable and even chat with your finances. It can even build you a custom financial dashboard!
Claude 1. Open [claude.ai](https://claude.ai) (or the Claude desktop app) and go to **Settings → Connectors**. 2. Click **Add custom connector**. 3. Name it *Fintable*, paste `https://fintable.io/mcp` as the server URL, and click **Add**. ![Adding the Fintable custom connector in Claude](/images/guide/claude-add-custom-connector.png) 4. Claude opens a Fintable window: log in, review the consent screen, and click **Authorize**. 5. In a chat, make sure the Fintable connector is enabled in the tools menu — then just ask. 6. Recommended for convenience: back in **Settings → Connectors**, open the *Fintable* connector's tool settings and set the **read-only tools** — everything starting with `get_` or `list_`, plus `search_institutions` — to **Always allow**. Claude can then answer questions about your finances without pausing to ask permission for every lookup, while write actions (categorizing, syncing, deleting) still ask first. ![Setting the Fintable read-only tools to Always allow in Claude](/images/guide/claude-always-allow-read-only-tools.png)
ChatGPT > **Known limitation:** The ChatGPT integration is a bit finicky right now due > to ChatGPT's limited integration with MCP servers. We're working to fix this > soon with a dedicated ChatGPT plugin. > Custom MCP apps currently work in ChatGPT on the web. Fintable's full > read/write tool set requires ChatGPT Business, Enterprise, or Edu; ChatGPT Pro > currently supports custom apps only for read/fetch access. Workspace > permissions may also apply. See OpenAI's current > [MCP app requirements](https://help.openai.com/en/articles/12584461). 1. In ChatGPT on the web, open **Settings → Apps → Advanced settings** and turn on **Developer mode**. If the option is unavailable, ask your workspace admin to enable access. Business admins can also start from **Workspace settings → Apps → Create**. 2. In **Settings → Apps**, click **Create**. 3. Name the app *Fintable*, paste `https://fintable.io/mcp` as the MCP server URL, and choose **OAuth** authentication. 4. Click **Scan tools**. In the Fintable window, log in, review the consent screen, click **Authorize**, and wait for the scan to finish. 5. Click **Create**. In a new chat, select the *Fintable* app from the tools menu (it may be labeled **Dev**) or refer to Fintable in your prompt.
From there, just ask. Prompts that work well: - *"Connect my Chase account to Fintable."* — the assistant looks up the bank and hands you a secure link to complete the login yourself, in your own browser. Your bank credentials never pass through the AI. - *"Sync all my accounts and tell me when it's finished."* - *"Set up sensible spending categories for me, then add rules so new transactions get categorized automatically."* - *"Categorize everything from last month and flag anything unusual."* - *"How much did I spend on restaurants this year compared to last year?"* - *"Rename my accounts so it's obvious which is checking and which is savings."* - *"Is my Airtable integration healthy? If the last sync failed, tell me why."* The MCP tools mirror the [Fintable API](https://fintable.io/docs) one-to-one, with the same permissions and the same rate limits — anything the API can do, your AI assistant can do. --- # 🏦 Bank Connections ## Comparing Providers (Plaid, GoCardless (Nordigen), Akoya, etc.) ### Plaid - Only supports US and Canadian banks - Realtime syncs and scheduled syncs are available but expensive. We need to calculate a charge for this based on your usage. - Usually only 2 years of historical data available - The largest bank network in the US and Canada, but still many connection issues with major banks like Chase and American Express. - Currently we do not support investment holdings data or Liabilities through Plaid, but we are working on this. ### GoCardless (Nordigen) - Supports EU banks through Open Banking - Usually 7+ years of historical data available - Does not support frequent syncs, but allows for daily syncs. Rate limits apply (officially 4 requests per day), although in practice this is usually twice per day. - Need to be reconnected every 90 days due to Open Banking regulations. Sometimes duplicates occur when reconnecting. ### Akoya - Supports US banks through Open Banking (more secure than Plaid) - Supports realtime syncs and scheduled syncs - Supports investment holdings data for institutions like Fidelity, synced to a separate Holdings table (Airtable) or Holdings tab (Google Sheets) — see the **Holdings Fields** section below ### SnapTrade - Only supports investment accounts (stocks, ETFs, etc.) - Syncs your investment holdings (ticker symbol, shares owned, cost basis, and more) to a separate Holdings table (Airtable) or Holdings tab (Google Sheets) — see the **Holdings Fields** section below ### Tabs - Fintable's in-house bank connections built by directly connecting to bank APIs - Usually the fastest and most reliable connections with support for very frequent syncs - Supports only two banks at the moment (Mercury and PrivatBank for Business), but more coming soon ## Connecting Your Bank To connect your bank account: 1. Log into your Fintable Dashboard 2. Click the **Add** button from the main dashboard or "New Connection" from the left sidebar 3. Search for your bank or financial institution 4. Follow the secure authentication process through your bank's website. This part is not handled by Fintable but by our bank connection providers (Plaid or GoCardless (Nordigen)). 5. Select which accounts you want to sync. This is usually done on the bank's website during the authentication process, but you can also disable accounts later in the Connection Settings. 6. Set your Sync Start Date (how far back you want transactions). See the **Sync Start Date** section below for details. 7. Run a manual sync to fetch your initial data. ## Sync Start Date: Retrieving Historical Transactions When you connect a new account, Fintable only fetches 30 days of transaction history by default. However, you can adjust this to retrieve more historical data. Note that the maximum historical data available depends on your bank and the connection provider. Most providers like Plaid can retrieve about 2 years of history. Some like GoCardless may be able to provide up to 7 years or more. ### Set the Sync Start Date for all accounts - Click the dropdown next to the **Add** button - Select **Sync Start Date** - Choose the date you want to start syncing - Click **Save** to apply the changes, and then run a manual sync ### Set the Sync Start Date for specific accounts - Click a specific bank connection - Go to **Connection Settings** - Adjust the **Sync Start Date** field for each account separate or modify the **Sync Start Date** for all accounts in the connection - Allow up to one hour for historical data to sync After setting the Sync Start Date, you will need to re-run a manual sync. ## How and When to Run a Sync ### 1. Automatic Daily Sync Your accounts sync automatically every 6-23 hours. The system varies the timing to distribute load and comply with bank API limits. Currently we are not able to adjust the daily sync time, but this is a feature we are considering for the future. ### 2. Manual Retry Daily Sync If you adjust the Sync Start Date, add a new spreadsheet integration, or adjust other settings, you can manually trigger a sync from your dashboard. This will not actually fetch new transactions from the bank data provider but will simply re-run the sync process to reflect your new settings. To manually trigger a sync: use the "Sync" button in your dashboard beside each bank connection. ### 3. Realtime Sync Office and Enterprise users can trigger "on-demand" or "realtime" syncs from their dashboard for immediate updates. This will make an attempt to fetch new transactions from the bank data provider immediately, including transactions that were made in the past hour. Note that this is not guaranteed to work for all banks, as some banks may not provide real-time data updates, and providers such as GoCardless have rate limits of 4 requests per day. ### 4. Sync Schedule Requests If you need more frequent syncs, or need to run a sync at a specific time, you can request a custom sync schedule. This is available for Office and Enterprise plans. To do this, go to your **Connection Settings** and click the **Request a Custom Sync Schedule** button. Our team will review your request and get back to you. ## Re-Authenticating Bank Connections Banks require periodic re-authentication for security, or if the connection fails. **EU Banks**: Re-authenticate every 90 days due to Open Banking regulations. To re-authenticate: 1. Go to **Connection Settings** 2. Look for connections marked with a red indicator 3. Click **Reconnect** and follow the prompts 4. Complete the authentication through your bank's website You may also delete and re-add the connection if you prefer to start fresh. However, take care to either adjust the Sync Start Date or delete the existing data first, to avoid duplicate transactions. ### MFA or Two-Factor Authentication If your bank is connected through Plaid, and you have multi-factor authentication (MFA) enabled, you may need to reconnect your bank every day or every sync. This is a limitation of Plaid's connection method and is not something we can control. --- # 💰 Data Fields (Columns) We attempt to standardize and aggregate data from different banks and providers into a simple format documented below. Some fields are optional and need to be configured and added manually - they are NOT synced by default. Currently, Fintable does not allow you to rename any of the required or optional fields in Airtable. In Google Sheets, however, you can rename the columns as you wish. ## If you can't find a field, check the Raw Data If you are looking for a data point not listed here, first check the optional columns section below, and then check the raw data from the provider to see if that data point is available. Common data points that our customers look for include: "IBAN number of counterparty", "reference number", "invoice IDs" or other metadata to reconcile and settle payments. Unfortunately, these data points are not always available from the bank data providers, but if they are, we can help you add them as a custom column. ### Transaction Raw Data The raw transaction data is available in the **Raw Data** column in Airtable or Google Sheets. It is also available in the **Transaction Details** section in the Fintable dashboard. Find the transaction you are interested in, and click the "..." dropdown to find the "View Raw Data" button. ### Account Raw Data The raw account data is available in the **Connection Settings** section in the Fintable dashboard. Under the **Account Settings** section, click the Settings button next to the account you are interested in, and then click the "Raw data from Account API provider" accordion. ### Default Fields (Required) - Fintable only updates specific fields (marked with \*asterisks in Airtable) - Fields with a single asterisk (\*) are never overwritten by Fintable if you modify them, but fields with two asterisks (\*\*) are overwritten upon a Force Re-Sync To see whether your fields/columns are set up correctly, you can use the **Validate** button in your Airtable or Google Sheets integration settings. **Account Fields**: - **\*\*Account Name**: The name of your bank account (may be edited on the dashboard) - **\*\*XXX Balance**: Current account balance where XXX is the currency code (e.g., USD, EUR, GBP) - **\*\*Institution**: Bank or financial institution name (may be edited on the dashboard) - **\*\*Last Update**: When account was last synced with the provider (manual retry syncs will not update this) - **\*\*Account ID**: Unique account identifier **Transaction Fields**: - **\*Name**: Transaction description - **\*\*Date**: Transaction date - **\*\*XXX**: Transaction amount in account currency (where XXX is the currency code, e.g., USD, EUR, GBP) - **\*\*Account**: Which account the transaction belongs to - **\*\*Category**: Transaction category - **\*\*Plaid TX ID**: Unique transaction identifier (for legacy reasons, still called Plaid TX ID, but it actually applies to all providers) ### Optional Fields (Add Manually) **Available Balance Fields**: - **\*\*USD Available**: Current available balance for spending - **\*\*EUR Available**: Available balance for EUR accounts - **\*\*GBP Available**: Available balance for GBP accounts **Additional Transaction Fields**: - **\*\*Auth Date**: When transaction was authorized - **\*\*Category Header**: Category grouping - **\*Vendor**: Merchant name - **\*Sub-Account**: Account holder name (for joint accounts) - **\*\*Check Number**: For check transactions - **\*Notes**: Additional transaction details ### Holdings Fields (Investment Accounts) If you connect an investment account (through SnapTrade or Akoya), Fintable also syncs your holdings — one row per security you own, updated on every sync. Holdings live in their own **Holdings** table (Airtable) or **Holdings** tab (Google Sheets), which Fintable creates automatically on the first sync — no setup needed. - **\*\*Name**: Holding name, usually the security name or ticker symbol - **\*\*Symbol**: Ticker symbol of the security - **\*\*Account**: The account the holding belongs to - **\*\*Quantity**: Number of shares or units you own - **\*\*Last Price**: Most recent price per share - **\*\*Last Price Change**: Change in price since the previous sync - **\*\*Current Value**: Current total value of the holding - **\*\*Today's Gain/Loss $** and **%**: Amount and percentage gained or lost today - **\*\*Total Gain/Loss $** and **%**: Amount and percentage gained or lost since purchase - **\*\*Cost Basis**: Total amount paid for the position - **\*\*Cost Basis Average**: Average cost per share - **\*\*Last Updated**: When the holding was last updated - **\*\*Holding ID**: Unique identifier required by Fintable to sync properly — you may hide this column, but do not delete it In Google Sheets the same columns appear with a lightning bolt (⚡) prefix, and you can enable, disable or rename them from the **Columns Holdings** page in your integration settings (there is also an optional **⚡ Account ID** column, disabled by default). In Airtable the holdings field names cannot be changed, same as Accounts and Transactions. ### How to Add Optional Fields **For Airtable**: 1. Go to your Airtable base 2. Add a new field with the exact name (e.g., "\*\*USD Available") 3. Set the field type correctly (Currency for balance fields) 4. The field will automatically populate on the next sync **For Google Sheets**: 1. Optional fields are pre-defined in your template 2. Go to your integration settings 3. Enable the fields you want in the column mapping (you can also rename them) 4. Save your configuration ## Pending Transactions **Important**: Fintable does not sync pending transactions. Due to the nature of pending transactions, there may sometimes be a delay in transaction data appearing in your spreadsheets. So even though Fintable syncs your transactions daily, you may not see all recent transactions for 2-3 business days. --- # 📊 Spreadsheet Integrations ## Airtable Integration **Understanding Airtable Sync** - Each Fintable account connects to one Airtable base - You may configure the name of the tables "Accounts" and "Transactions" in your Airtable base, but you cannot change the field names currently. - Need multiple Airtable bases? Use Sub Accounts (Office Plan feature) - Currently, Fintable does not allow you to rename any of the required or optional fields in Airtable. - Fintable only updates specific fields (marked with \*asterisks in Airtable) - Fields with a single asterisk (\*) are never overwritten by Fintable if you modify them, but fields with two asterisks (\*\*) are overwritten upon a Force Re-Sync - You can add custom columns—they won't be affected - Sync is one-way: Fintable → Airtable - Investment accounts also sync to a **Holdings** table, created automatically on the first sync — see the **Holdings Fields** section above - If you delete your bank connection, Fintable will not delete transactions in Airtable. You can manually delete them if needed. - During regular syncs, Fintable will NOT attempt to re-write a transaction that has already been synced before. However, if you run a Force Re-Sync, or remove and re-connect your Airtable, Fintable will re-write all transactions, including those that were previously written. **Balance-Only Sync Option** You may configure your Airtable integration to sync only account balances without syncing transactions. When transaction sync is disabled: - Only account balances and basic account information will be synced - You may remove the Transactions table from your Airtable base **What Happens If You Delete Transactions in Airtable**: If you delete a transaction directly in Airtable, it will not be re-synced immediately. However, if you run a Force Re-Sync, or disconnect and reconnect your Airtable integration, Fintable will re-sync all transactions, including the deleted ones. ### Force Re-Sync If you need to re-sync all transactions, including those that were previously written, you can use the **Force Re-Sync** button from the Airtable integration settings in your Fintable dashboard. This will overwrite all existing transactions in Airtable with the latest data in Fintable, including restoring any transactions that may have been deleted in Airtable. Sometimes this is necessary after adding an optional field. ## Google Sheets Integration [Quick Start Video for Google Sheets Integration](https://youtu.be/-xEk0s5Iypo) **Common Issues**: - Make sure you use the same Google Account to copy the Fintable template and connect your bank accounts. The "Use Template" will copy to your "main" Google account, but if this is not the account you use to connect to Fintable, this will not work. - By default, Fintable adds new rows to the bottom of your Google Sheet. You need to sort by date to see the most recent transactions at the top. **What You Can Do**: - Add custom columns for notes, tags, or calculations - Create additional rows for summaries or categories - Use formulas and pivot tables - Investment accounts also sync to a **Holdings** tab, created automatically on the first sync — see the **Holdings Fields** section above. Configure its columns from the **Columns Holdings** page in your integration settings. - Add optional fields from the **How to Add Optional Fields** section above - Move the data around, so that you add rows above the Fintable data, for example. However, then you need to update the column mapping in the Fintable integration settings to match your new layout. --- # 👥 Sub Accounts A "Sub Account" is essentially just another Fintable account under a main Fintable account. People create multiple Fintable accounts for many reasons. - Share and/or collect data from multiple clients or business entities with separate data flows - Keep personal and business finances separate - Each sub-account inherits your billing plan but maintains separate connections and settings. - Connect different sets of bank accounts to different Airtable bases (since each Fintable account can connect to one Airtable) The main advantages of Sub Accounts (instead of just creating multiple Fintable accounts yourself are): 1. Billed under one plan, so pay only for 1 account. 2. Easily switch between multiple Fintable accounts using the Quick Switcher, without logging out and logging back in. Sub Accounts may be created for yourself (i.e. different countries or legal entities) or for other people (i.e. clients). Sub Accounts are only available on the Office plan, not the Personal plan. ## Create and manage Sub Accounts Create a Sub Account from the Sub Accounts Dashboard within the Fintable dashboard. All you need is a name and email. **Important:** Anyone with access to the email address you use to create a Sub Account will be able to log into Fintable with that email, but they will only have access to their Sub Account, not your main account. Once you have created a Sub Account you may quickly switch between your main account and your sub accounts using the Quick Switcher on the top-right of the dashboard. ## Share a Sub Account with a client or someone else If you have created a Sub Account for someone else (e.g. client), you should notify that person that they have a Fintable account active with that email address. Fintable does NOT automatically notify them. **Sub Accounts do not receive any email notifications by default.** They can login to their Sub Account at [fintable.io/login](https://fintable.io/login) using the email address you used to create the Sub Account. They may use the Forgot Password feature to reset the password and login, or you may reset their password for them and share their login credentials (email/password) with them directly. --- # 🛠 Troubleshooting ## Connection Problems **Red Traffic Light Indicators**: If you see red indicators in your Connection Settings: 1. Check the error message in your sync summary 2. Try the "Reconnect" button 3. Wait 24 hours and try again (some errors resolve automatically) 4. Contact support if the issue persists **Common Error Messages**: - "Item login required" → Reconnect your account - "Invalid credentials" → Your bank password may have changed - "Problem connecting" → Usually resolves within 24 hours - "MFA required" → Complete multi-factor authentication - "Rate limit exceeded" → Wait 24 hours before retrying --- # 📈 Usage and Limits ## Bank Connection Limits Your plan determines how many bank connections you can have: - **Free Trial**: 3 bank connections (limited to 30 days history). Contact support to ask for higher limits. - **Personal**: 10 bank connections - **Office**: 50 bank connections - **Enterprise**: By agreement If you need more connections, you can upgrade your plan at any time, or contact support. ## Transaction Volume **How Transactions Are Counted**: - All transactions count toward your limit (incoming, outgoing, transfers) - Pending transactions are counted (even if they later disappear) - Internal transfers between your accounts are counted **Volume Limits by Plan**: - **Personal**: $1,000,000 annual transaction volume - **Office**: Unlimited transaction volume If you exceed your limit, you'll be notified and may need to upgrade your plan. We also consider requests to increase your limits on a case-by-case basis, when usage is clearly for non-commercial purposes on the Personal plan. If you exceed your limits you won't lose any data, the sync will continue, but you will not be able to add any new bank connections. --- # 🌐 Bank Coverage To see an up-to-date list of supported banks, visit our [Coverage page](https://fintable.io/coverage). We're constantly looking to add more banks and improve our coverage. ## Requesting New Banks If your bank isn't supported: 1. Submit a request through [this form](https://airtable.com/shreZS1SsWIrkHlYj) 2. We'll notify you if/when your bank becomes available If you need an urgent API connection for a specific bank, please contact us for a custom integration quote. Typically this involves about a $5,000 one-time fee for the integration development on a two-week timeline. --- # 📞 Support and Contact ## Getting Help **Self-Service Options**: - Use the **Validate** button to check your configuration - Check Connection Settings for error messages - Review this guide for common issues **Contact Us**: - Email us at [support@fintable.io](mailto:support@fintable.io). Make sure to send from the same email address you used to sign up for Fintable or we may ignore your request. - Alternatively, you can use the in-app chat form by clicking the chat icon in the bottom right corner of your dashboard. - Include your Link ID or Account ID for faster resolution - Screen recordings are helpful for complex issues --- # Restricted Countries Fintable does not provide services to OFAC-sanctioned countries and customers in the following countries. - Afghanistan - Belarus - Burma (Myanmar) - Central African Republic - Cuba - Democratic Republic of the Congo - Ethiopia - Iran - Iraq - Israel - Libya - Mali - North Korea - Russia - Somalia - South Sudan - Sudan and Darfur - Syria - Venezuela **Customer Responsibility**: Customers are responsible for ensuring compliance with applicable sanctions and export control laws in their jurisdiction. **Service Availability**: Even if a country is not listed above, banking connections and services may still be limited based on the availability of supported financial institutions and local regulations. For questions about service availability in specific countries, please contact our support team.