BlogDevelopers

How to Sync Bank Transactions to Postgres with the SheetLink CLI

A developer's guide to piping Plaid bank transactions directly into a Postgres database using the SheetLink CLI: setup, schema, and automation.

The SheetLink CLI syncs your transactions into Postgres or SQLite with Max.

If you're a developer who manages your own finances and you've ever thought "I wish I could just query my transactions with SQL," then the SheetLink CLI is built for you.

This guide walks through installing the CLI, authenticating, and syncing your Plaid-connected bank accounts directly into a Postgres database.

Prerequisites

  • Node.js 18+
  • A Postgres database (local, Railway, Supabase, Neon, RDS, or anything with a connection string)
  • A SheetLink account with the Max plan (CLI automation + database output require Max)
  • At least one bank connected via the SheetLink dashboard

Install the CLI

npm install -g sheetlink

Verify the install:

sheetlink --version

Authenticate

sheetlink auth

This opens a browser window for Google sign-in. After authenticating, your credentials are stored locally.

Run Your First Sync to Postgres

sheetlink sync --output postgres://localhost/mydb

SheetLink will:

  1. Connect to your SheetLink account and fetch connected banks
  2. Pull transactions via Plaid
  3. Create a sheetlink_transactions table if it doesn't exist
  4. Upsert all transactions: no duplicates, no matter how many times you run it

You should see output like:

  Chase ................. 23 transactions
  Bank of America ....... 31 transactions
  54 rows upserted → sheetlink_transactions
✓ sync complete

The Schema

Two tables are created automatically on first sync. The full schema matches what the SheetLink Google Sheets extension writes (all 34 Plaid transaction fields), plus a legacy category column.

CREATE TABLE sheetlink_transactions (
  transaction_id           TEXT PRIMARY KEY,
  account_id               TEXT,
  persistent_account_id    TEXT,
  account_name             TEXT,
  account_mask             TEXT,
  date                     DATE NOT NULL,
  authorized_date          DATE,
  datetime                 TEXT,
  authorized_datetime      TEXT,
  description_raw          TEXT,
  merchant_name            TEXT,
  merchant_entity_id       TEXT,
  amount                   DECIMAL(10,2),
  iso_currency_code        TEXT,
  unofficial_currency_code TEXT,
  pending                  BOOLEAN,
  pending_transaction_id   TEXT,
  check_number             TEXT,
  category_primary         TEXT,
  category_detailed        TEXT,
  payment_channel          TEXT,
  transaction_type         TEXT,
  transaction_code         TEXT,
  location_address         TEXT,
  location_city            TEXT,
  location_region          TEXT,
  location_postal_code     TEXT,
  location_country         TEXT,
  location_lat             DECIMAL(10,7),
  location_lon             DECIMAL(10,7),
  website                  TEXT,
  logo_url                 TEXT,
  source_institution       TEXT,
  category                 TEXT,
  synced_at                TIMESTAMP DEFAULT NOW()
);

CREATE TABLE sheetlink_accounts (
  account_id            TEXT PRIMARY KEY,
  persistent_account_id TEXT,
  name                  TEXT,
  official_name         TEXT,
  mask                  TEXT,
  type                  TEXT,
  subtype               TEXT,
  current_balance       DECIMAL(10,2),
  available_balance     DECIMAL(10,2),
  iso_currency_code     TEXT,
  institution           TEXT,
  last_synced_at        TIMESTAMP DEFAULT NOW()
);

Upsert key: transaction_id, Plaid's unique identifier. Running sync repeatedly is always safe.

category_primary and category_detailed are Plaid's personal finance category fields (e.g. FOOD_AND_DRINK / FOOD_AND_DRINK_RESTAURANTS). category is the legacy Plaid category array joined as a string, kept for backwards compatibility. description_raw is the raw transaction name as Plaid returns it.

If you're migrating from an older version or just need the core fields, --slim writes the original 14-column schema:

sheetlink sync --output postgres://localhost/mydb --slim

Querying Your Data

Once synced, your transactions are queryable like any other table:

-- Monthly spending by category
SELECT
  DATE_TRUNC('month', date) AS month,
  category_primary,
  SUM(amount) AS total
FROM sheetlink_transactions
WHERE amount > 0
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;
-- Top merchants last 90 days
SELECT
  COALESCE(merchant_name, description_raw) AS merchant,
  SUM(ABS(amount)) AS total_spent
FROM sheetlink_transactions
WHERE date >= NOW() - INTERVAL '90 days'
  AND amount > 0
GROUP BY 1
ORDER BY total_spent DESC
LIMIT 20;
-- Monthly income
SELECT
  DATE_TRUNC('month', date) AS month,
  -SUM(amount) AS income
FROM sheetlink_transactions
WHERE amount < 0
GROUP BY 1
ORDER BY 1 DESC;

Tip

Amounts follow Plaid's sign convention: positive = money out (expense), negative = money in (income/deposit).

Other Output Formats

The --output flag supports more than Postgres:

sheetlink sync --output sqlite://finances.db
sheetlink sync --output csv
sheetlink sync --output csv --file ~/finances.csv
sheetlink sync

The last command outputs JSON to stdout, pipeable to jq or any other tool.

Automating with Cron

After a one-time sheetlink auth --api-key sl_... (with an API key from your SheetLink dashboard), the CLI runs without user interaction. Add a daily cron job:

# Sync every morning at 7am
0 7 * * * /usr/local/bin/sheetlink sync --output postgres://user:pass@host/dbname >> /var/log/sheetlink.log 2>&1

Or use a process manager like pm2 if you prefer:

pm2 crontab -- "0 7 * * * sheetlink sync --output postgres://..."

Note

If you're running this on a server, store your Postgres credentials in an environment variable rather than the connection string directly: sheetlink sync --output $DATABASE_URL

Connecting Remote Databases

The --output flag accepts any standard Postgres connection string:

# Supabase
sheetlink sync --output postgres://postgres:[password]@db.[project].supabase.co:5432/postgres

# Railway
sheetlink sync --output postgresql://postgres:[password]@[host].railway.app:5432/railway

# Neon
sheetlink sync --output postgres://[user]:[password]@[host].neon.tech/neondb

What Max Includes

Postgres and SQLite output are Max features. Max also includes:

  • API keys, so sheetlink sync can run unattended from cron or CI
  • sheetlink investments: holdings and investment activity, with cost basis and gain or loss
  • Claude, through the SheetLink MCP server

SheetLink Max

Everything in Pro, plus CLI automation, Postgres and SQLite output, investments, and Claude.

Compare plans ›

Full refund within 14 days. Cancel anytime.

Questions

What Postgres version does SheetLink support?

SheetLink works with any Postgres instance accessible via a standard connection string: local, Railway, Supabase, RDS, Neon, or self-hosted.

Does SheetLink create the table automatically?

Yes. SheetLink creates a sheetlink_transactions table if it doesn't exist, and upserts rows on transaction_id to prevent duplicates.

What's the schema of the transactions table?

sheetlink_transactions has 35 columns covering the full Plaid transaction object: IDs, dates (date, authorized_date, datetime), description_raw, merchant info, amount, currency, category_primary + category_detailed, location fields, payment channel, and more. It's the same schema as the Google Sheets extension and Excel add-in, plus a legacy category column. A separate sheetlink_accounts table is also created. Use --slim for a legacy 14-column subset.

Can I run SheetLink CLI in a cron job?

Yes, with a Max API key. After authenticating once with sheetlink auth --api-key, the CLI stores the key locally and can run unattended. A daily cron like `0 7 * * * sheetlink sync --output postgres://...` works reliably.

Is Postgres output a Max feature?

Yes. Database output (Postgres, SQLite) and CLI automation require the Max plan ($10.99/mo or $99/yr).

See it work.

Schedule sheetlink sync and send transactions to Postgres or SQLite. CLI automation is part of Max. About the CLI ›

Keep reading.

Your bank data, in your control.

Free for one bank and your last 7 days. No card needed.