SheetLink
developer

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.

Rudy·Founder, SheetLink
··4 min read

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" — 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.

  • Node.js 18+
  • A Postgres database (local, Railway, Supabase, Neon, RDS — anything with a connection string)
  • A SheetLink account with MAX plan (CLI + database output require MAX)
  • At least one bank connected via the SheetLink dashboard
$npm install -g sheetlink

Verify the install:

$sheetlink --version
$sheetlink auth

This opens a browser window for Google sign-in. After authenticating, your credentials are stored locally and the CLI can run unattended.

$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

Two tables are created automatically on first sync. The full schema matches what the SheetLink Google Sheets extension writes — all 34 Plaid transaction fields.

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

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 ASC;
-- 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;

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.

After the initial sheetlink auth, 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://..."

ℹ️ Info

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

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

Postgres, SQLite, CSV, and JSON output are all MAX features. MAX also includes:

  • REST API access with API key auth
  • sheetlink items — list connected banks
  • sheetlink config — manage CLI settings
  • No rate limits on sync frequency
postgresclideveloperautomationplaiddatabaseMAX

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

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

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 — same schema as the Google Sheets extension and Excel add-in. A separate sheetlink_accounts table is also created. Use --slim for a legacy 14-column subset.

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

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