Plaid — Bank Account Linking & Transaction Sync
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Your app needs real bank data — transactions for a P&L, balances for a dashboard, a
bookkeeping tool. Plaid is the bridge: the user clicks "Link account", authenticates
with their bank inside Plaid's hosted Link widget, and your app gets an
access_token it can use to pull accounts and transactions forever after.
This recipe is a complete, production-proven integration with zero gems. The
official plaid gem is not in the base image and you cannot add gems on a Leo instance
— so the service module below talks to Plaid's JSON API directly with Net::HTTP from
the standard library. It is the whole integration: Link token creation, the token
exchange, cursor-based incremental transaction sync in a background job, webhook-driven
updates, historical backfill by date range, and a connection-health state machine that
handles the day the user's bank login expires.
When to use: pulling a user's real bank transactions and balances into your app — bookkeeping, P&L reports, spend dashboards, budgeting tools. When not to: taking payments (that's Stripe), or when the user only has a handful of transactions to track (a manual entry form is a day less work and zero per-item fees — Plaid bills per connected Item).
The 80/20 in one breath
- Get a client_id and secret from the Plaid dashboard
(
dashboard.plaid.com). Start in sandbox; request production access later. - Add
PLAID_CLIENT_ID,PLAID_SECRET,PLAID_URL, andBASE_URLto.env, then recreate the web container (a plain restart does not reload.env). - Run the two migrations below (
plaid_institutions,plaid_accounts) plus the Plaid columns on yourtransactionstable. - Copy
PlaidServiceintoapp/services/plaid_service.rb. - Wire the controller + routes:
GET /plaidrenders the Link page,POST /plaid/exchange_public_tokenstores the access token and accounts,POST /plaid/webhookreacts to Plaid's pushes. - Copy the sync job. Link a sandbox bank (
user_good/pass_good) and watch transactions appear.
Layer 0 — Environment keys (.env)
# .env (project root, next to docker-compose.yml — compose passes this file
# into the web container via `env_file: .env`)
# REQUIRED — from dashboard.plaid.com → Team Settings → Keys
PLAID_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxx
PLAID_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # sandbox and production secrets DIFFER
# REQUIRED — which Plaid environment the secret belongs to.
# Sandbox: https://sandbox.plaid.com/
# Production: https://production.plaid.com/
PLAID_URL=https://sandbox.plaid.com/
# REQUIRED for webhooks — your app's public https URL.
# The webhook URL registered with Plaid becomes "#{BASE_URL}/plaid/webhook".
BASE_URL=https://yourapp.example.com
After editing .env: docker compose down llamapress && docker compose up -d llamapress.
Layer 1 — Model & SQL
Three tables carry the integration. A PlaidInstitution is one Plaid Item — one
login at one bank (it owns the access_token, the sync cursor, and the health state).
A PlaidAccount is one account inside that Item (checking, savings, a credit card).
Transactions belong to both.
# db/migrate/XXXXXXXXXXXXXX_create_plaid_institutions.rb
class CreatePlaidInstitutions < ActiveRecord::Migration[8.0]
def change
create_table :plaid_institutions, id: :uuid do |t|
t.references :user, type: :uuid, null: false, foreign_key: true
t.text :access_token # the long-lived Plaid credential
t.text :item_id # Plaid's ID for this login (Item)
t.text :institution_id_plaid, null: false # the BANK's ID (e.g. ins_120013) — NOT unique per Item!
t.text :institution_name, null: false
# Incremental sync state
t.text :transactions_cursor # cursor for transactions/sync
t.boolean :initial_sync_complete, default: false
t.boolean :historical_sync_complete, default: false
t.datetime :last_sync_at
# Connection-health state machine
t.text :status, default: "active" # active | needs_reauth | pending_expiration | pending_disconnect | error
t.boolean :needs_update, default: false
t.text :last_error_code
t.text :last_error_message
t.datetime :last_successful_sync_at
t.datetime :last_failed_sync_at
t.datetime :consent_expiration_time
t.timestamps
end
end
end
# db/migrate/XXXXXXXXXXXXXX_create_plaid_accounts.rb
class CreatePlaidAccounts < ActiveRecord::Migration[8.0]
def change
create_table :plaid_accounts, id: :uuid do |t|
t.references :user, type: :uuid, foreign_key: true
t.references :plaid_institution, type: :uuid, foreign_key: true
t.text :account_id_plaid, null: false # Plaid's account ID (changes on re-link!)
t.text :item_id, null: false
t.text :name, null: false # "Plaid Checking"
t.text :account_type, null: false # depository | credit | loan | investment
t.text :subtype # checking | savings | credit card ...
t.text :mask # last 4 digits — the STABLE identifier
t.decimal :balance_available
t.decimal :balance_current
t.decimal :balance_limit
t.text :iso_currency_code, default: "USD"
t.timestamps
end
# One Plaid account ID per institution; mask+name is the human-stable identity
add_index :plaid_accounts, [:plaid_institution_id, :account_id_plaid], unique: true
add_index :plaid_accounts, [:user_id, :mask, :name], unique: true
end
end
# db/migrate/XXXXXXXXXXXXXX_add_plaid_columns_to_transactions.rb
# Assumes you already have a transactions table (user_id, amount, date, description).
class AddPlaidColumnsToTransactions < ActiveRecord::Migration[8.0]
def change
add_reference :transactions, :plaid_account, type: :uuid, foreign_key: true
add_reference :transactions, :plaid_institution, type: :uuid, foreign_key: true
add_column :transactions, :transaction_id_plaid, :text # dedup key from Plaid
add_column :transactions, :account_id_plaid, :text
add_column :transactions, :name_plaid, :text
add_column :transactions, :merchant_name, :text
add_column :transactions, :category_list_plaid, :text, array: true, default: []
add_column :transactions, :category_id_plaid, :text
add_column :transactions, :pending, :boolean, default: false
add_column :transactions, :transaction_type, :text
add_column :transactions, :iso_currency_code, :text, default: "USD"
add_index :transactions, :transaction_id_plaid
end
end
The institution model carries the health state machine — this is what makes the integration survive month two, when bank logins start expiring:
# app/models/plaid_institution.rb
class PlaidInstitution < ApplicationRecord
belongs_to :user
has_many :plaid_accounts, dependent: :destroy
has_many :transactions, dependent: :nullify
enum :status, {
active: "active",
needs_reauth: "needs_reauth",
pending_expiration: "pending_expiration",
pending_disconnect: "pending_disconnect",
error: "error"
}, validate: true
scope :healthy, -> { where(status: :active, needs_update: false) }
def record_sync_success!
update!(
last_sync_at: Time.current, last_successful_sync_at: Time.current,
last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil,
needs_update: false, status: :active
)
end
def record_sync_failure!(error_code:, error_message:)
update!(
last_error_code: error_code, last_error_message: error_message,
last_failed_sync_at: Time.current, needs_update: true,
status: error_code == "ITEM_LOGIN_REQUIRED" ? :needs_reauth : :error
)
end
end
# app/models/plaid_account.rb
class PlaidAccount < ApplicationRecord
belongs_to :user
belongs_to :plaid_institution
has_many :transactions, dependent: :nullify
end
Layer 2 — The service module (plain Net::HTTP, no gem)
Every Plaid endpoint is a JSON POST with client_id + secret in the body. That's the
whole API surface — one private post_request helper covers everything.
# app/services/plaid_service.rb
require "net/http"
require "uri"
require "json"
module PlaidService
extend self
# Create a Link token. Two modes:
# - New connection: omit access_token → requests the 'transactions' product.
# - RE-AUTH (update mode): pass the institution's stored access_token →
# Link opens against the existing Item so the user can refresh their login.
def create_link_token(client_id:, secret:, user_id:, webhook_url: nil, access_token: nil)
body = {
client_id: client_id,
secret: secret,
user: { client_user_id: user_id.to_s }, # must be unique per user
client_name: "My Finance App", # shown inside the Link UI
country_codes: ["US"],
language: "en"
}
if access_token.present?
body[:access_token] = access_token # update mode — no products key
else
body[:products] = ["transactions"]
# Plaid only backfills 90 days by default. Ask for 2 years UP FRONT —
# this cannot be changed after the Item is created.
body[:transactions] = { days_requested: 730 }
end
body[:webhook] = webhook_url if webhook_url.present?
post_request("link/token/create", body)["link_token"]
end
# Trade the short-lived public_token from Link for the permanent access_token.
def exchange_public_token(client_id:, secret:, public_token:)
result = post_request("item/public_token/exchange",
client_id: client_id, secret: secret, public_token: public_token)
{ access_token: result["access_token"], item_id: result["item_id"] }
end
# Current accounts + balances for an Item.
def get_accounts(access_token:, client_id:, secret:)
post_request("accounts/balance/get",
client_id: client_id, secret: secret, access_token: access_token)["accounts"]
end
# Incremental transaction sync. Returns {'added', 'modified', 'removed',
# 'next_cursor', 'has_more'}. Pass cursor: nil on the very first call.
def get_transactions(access_token:, client_id:, secret:, cursor: nil)
post_request("transactions/sync",
client_id: client_id, secret: secret, access_token: access_token, cursor: cursor)
end
# One-off historical backfill for a date range (transactions/get, offset-paginated).
def get_transactions_by_date_range(access_token:, client_id:, secret:,
start_date:, end_date:, offset: 0, count: 500)
post_request("transactions/get",
client_id: client_id, secret: secret, access_token: access_token,
start_date: start_date, end_date: end_date,
options: { offset: offset, count: count })
end
# "Plaid error CODE: MESSAGE - ..." → [code, message]; feeds the health state machine.
def parse_plaid_error(error)
match = error.message.match(/Plaid error (\w+): (.*?) - /)
match ? [match[1], match[2]] : ["UNKNOWN", error.message.truncate(500)]
end
private
def base_uri
URI(ENV["PLAID_URL"] || "https://sandbox.plaid.com/")
end
def post_request(endpoint, body)
url = base_uri.merge(endpoint)
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url.path, "Content-Type" => "application/json")
request.body = body.to_json
response = http.request(request)
result = JSON.parse(response.body)
return result if response.is_a?(Net::HTTPSuccess)
# Keep this exact format — parse_plaid_error depends on it.
raise "Plaid error #{result['error_code']}: #{result['error_message']} - #{result.fetch('display_message', 'No display message')}"
end
end
Layer 3 — Controller & routes
# config/routes.rb (inside the authenticated scope, except the webhook)
get "plaid" => "plaid#new"
post "plaid/exchange_public_token" => "plaid#exchange_public_token"
post "plaid/sync_all" => "plaid#sync_all", as: :plaid_sync_all
post "plaid/institutions/:id/resync" => "plaid#resync", as: :plaid_institution_resync
post "plaid/webhook" => "plaid#webhook" # must be reachable WITHOUT auth
# app/controllers/plaid_controller.rb
class PlaidController < ApplicationController
# Plaid's servers call the webhook — no session, no CSRF token.
skip_before_action :verify_authenticity_token, only: [:webhook]
skip_before_action :authenticate_user!, only: [:webhook], raise: false
# GET /plaid — renders the Link page.
# With ?institution_id=<uuid> it becomes a RE-AUTH page for that institution.
def new
@plaid_institutions = current_user.plaid_institutions.order(created_at: :desc)
webhook_url = ENV["BASE_URL"].present? ? "#{ENV['BASE_URL']}/plaid/webhook" : nil
access_token = nil
if params[:institution_id].present?
@reauth_institution = current_user.plaid_institutions.find(params[:institution_id])
access_token = @reauth_institution.access_token
end
@link_token = PlaidService.create_link_token(
client_id: ENV["PLAID_CLIENT_ID"], secret: ENV["PLAID_SECRET"],
user_id: current_user.id, webhook_url: webhook_url, access_token: access_token
)
end
# POST /plaid/exchange_public_token — called by the Link onSuccess JS.
def exchange_public_token
client_id = ENV["PLAID_CLIENT_ID"]
secret = ENV["PLAID_SECRET"]
bank_id = params[:institution][:institution_id] # Plaid's BANK id, e.g. ins_120013
bank_name = params[:institution][:name]
if params[:plaid_institution_id].present?
# ── RE-AUTH: the frontend told us EXACTLY which of OUR institutions this is.
# In update mode the access_token does NOT change (and public_token may be
# null) — reuse the stored token, skip the exchange entirely.
plaid_institution = current_user.plaid_institutions.find(params[:plaid_institution_id])
access_token = plaid_institution.access_token
item_id = plaid_institution.item_id
else
# ── NEW CONNECTION: exchange the public_token for a permanent access_token.
tokens = PlaidService.exchange_public_token(
client_id: client_id, secret: secret, public_token: params[:public_token])
access_token = tokens[:access_token]
item_id = tokens[:item_id]
# Match against an existing institution before creating one:
# A. exact item_id match (same Item re-linked)
plaid_institution = current_user.plaid_institutions.find_by(item_id: item_id)
# B. else match by account mask+name — but ONLY within the same bank_id.
# Two logins at the same bank (personal + business) share bank_id and can
# even share account names; without this scoping they overwrite each other.
if plaid_institution.nil?
accounts = PlaidService.get_accounts(access_token: access_token,
client_id: client_id, secret: secret)
accounts.each do |acc|
existing = current_user.plaid_accounts.joins(:plaid_institution)
.where(plaid_institutions: { institution_id_plaid: bank_id })
.find_by(mask: acc["mask"], name: acc["name"])
if existing
plaid_institution = existing.plaid_institution
break
end
end
end
end
accounts ||= PlaidService.get_accounts(access_token: access_token,
client_id: client_id, secret: secret)
if plaid_institution
plaid_institution.update!(
item_id: item_id, access_token: access_token,
needs_update: false, status: :active,
last_error_code: nil, last_error_message: nil, consent_expiration_time: nil
)
else
plaid_institution = PlaidInstitution.create!(
user: current_user, item_id: item_id, access_token: access_token,
institution_id_plaid: bank_id, institution_name: bank_name
)
end
# Upsert accounts. Match by mask+name FIRST (stable across re-links),
# then by account_id_plaid (changes when the user re-links!).
accounts.each do |a|
plaid_account =
plaid_institution.plaid_accounts.find_by(mask: a["mask"], name: a["name"]) ||
plaid_institution.plaid_accounts.find_by(account_id_plaid: a["account_id"])
attrs = {
account_id_plaid: a["account_id"], item_id: item_id,
name: a["name"], account_type: a["type"], subtype: a["subtype"],
mask: a["mask"],
balance_available: a["balances"]["available"],
balance_current: a["balances"]["current"],
balance_limit: a["balances"]["limit"],
iso_currency_code: a["balances"]["iso_currency_code"] || "USD"
}
if plaid_account
plaid_account.update!(attrs)
else
plaid_institution.plaid_accounts.create!(attrs.merge(user: current_user))
end
end
PlaidTransactionSyncJob.perform_later(plaid_institution.id)
redirect_to root_path, notice: "Successfully linked your bank account!"
end
# POST /plaid/institutions/:id/resync — manual "pull now" button.
# If the connection is unhealthy, send the user to re-auth instead of syncing.
def resync
plaid_institution = current_user.plaid_institutions.find(params[:id])
if plaid_institution.needs_update? || !plaid_institution.active?
redirect_to plaid_path(institution_id: plaid_institution.id),
alert: "Your bank login for #{plaid_institution.institution_name} needs re-authentication."
return
end
PlaidTransactionSyncJob.perform_later(plaid_institution.id)
redirect_back fallback_location: root_path,
notice: "Syncing #{plaid_institution.institution_name}..."
end
def sync_all
current_user.plaid_institutions.each { |i| PlaidTransactionSyncJob.perform_later(i.id) }
redirect_back fallback_location: root_path, notice: "Syncing all accounts..."
end
# POST /plaid/webhook — Plaid pushes state changes here. ALWAYS return 200.
def webhook
webhook_code = params[:webhook_code]
plaid_institution = PlaidInstitution.find_by(item_id: params[:item_id])
Rails.logger.info "Plaid webhook: #{webhook_code} for item #{params[:item_id]}"
case webhook_code
when "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE", "SYNC_UPDATES_AVAILABLE"
PlaidTransactionSyncJob.perform_later(plaid_institution.id) if plaid_institution
when "TRANSACTIONS_REMOVED"
(params[:removed_transactions] || []).each do |tx_id|
Transaction.find_by(transaction_id_plaid: tx_id)&.destroy
end
when "ITEM_LOGIN_REQUIRED"
error = params[:error] || {}
plaid_institution&.update!(
status: :needs_reauth, needs_update: true,
last_error_code: error["error_code"] || "ITEM_LOGIN_REQUIRED",
last_error_message: error["error_message"] || "User login required",
last_failed_sync_at: Time.current
)
when "PENDING_EXPIRATION"
updates = { status: :pending_expiration, needs_update: true }
updates[:consent_expiration_time] = params[:consent_expiration_time] if params[:consent_expiration_time].present?
plaid_institution&.update!(updates)
when "PENDING_DISCONNECT"
plaid_institution&.update!(status: :pending_disconnect, needs_update: true)
when "LOGIN_REPAIRED"
plaid_institution&.update!(
status: :active, needs_update: false,
last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil
)
when "ERROR"
error = params[:error] || {}
plaid_institution&.update!(
status: :error, needs_update: true,
last_error_code: error["error_code"] || "WEBHOOK_ERROR",
last_error_message: error["error_message"] || "Webhook reported an error",
last_failed_sync_at: Time.current
)
end
head :ok # anything else = Plaid retries + may eventually disable the webhook
end
end
Layer 4 — The sync job (cursor-based, idempotent)
# app/jobs/plaid_transaction_sync_job.rb
class PlaidTransactionSyncJob < ApplicationJob
queue_as :default
def perform(plaid_institution_id)
plaid_institution = PlaidInstitution.find(plaid_institution_id)
user = plaid_institution.user
client_id = ENV["PLAID_CLIENT_ID"]
secret = ENV["PLAID_SECRET"]
has_more = true
cursor = plaid_institution.transactions_cursor # nil on the very first sync
while has_more
response = PlaidService.get_transactions(
access_token: plaid_institution.access_token,
client_id: client_id, secret: secret, cursor: cursor)
response["added"].each do |tx|
next if Transaction.exists?(transaction_id_plaid: tx["transaction_id"])
# Scope the account lookup to THIS institution — never assign a
# transaction to a same-named account on a sibling institution.
plaid_account = plaid_institution.plaid_accounts
.find_by(account_id_plaid: tx["account_id"])
next unless plaid_account
Transaction.create!(
user: user,
plaid_account: plaid_account,
plaid_institution: plaid_institution,
account_id_plaid: tx["account_id"],
transaction_id_plaid: tx["transaction_id"],
amount: tx["amount"], # Plaid: positive = money OUT
description: tx["name"],
name_plaid: tx["name"],
merchant_name: tx["merchant_name"],
category_list_plaid: tx["category"],
category_id_plaid: tx["category_id"],
date: tx["date"],
pending: tx["pending"],
transaction_type: tx["transaction_type"],
iso_currency_code: tx["iso_currency_code"]
)
end
(response["modified"] || []).each do |tx|
Transaction.find_by(transaction_id_plaid: tx["transaction_id"])&.update!(
amount: tx["amount"], description: tx["name"], name_plaid: tx["name"],
merchant_name: tx["merchant_name"], date: tx["date"], pending: tx["pending"])
end
(response["removed"] || []).each do |tx|
Transaction.find_by(transaction_id_plaid: tx["transaction_id"])&.destroy
end
cursor = response["next_cursor"]
has_more = response["has_more"]
# Persist the cursor EVERY page — a crash mid-sync resumes, not restarts.
plaid_institution.update!(transactions_cursor: cursor, last_sync_at: Time.current)
end
plaid_institution.record_sync_success!
rescue => e
error_code, error_message = PlaidService.parse_plaid_error(e)
plaid_institution.record_sync_failure!(error_code: error_code, error_message: error_message)
raise e
end
end
Layer 5 — The Link page (frontend)
<%# app/views/plaid/new.html.erb %>
<div class="max-w-lg mx-auto p-8">
<h1 class="text-2xl font-bold mb-4">Link a bank account</h1>
<button id="link-button"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<%= @reauth_institution ? "Re-authenticate #{@reauth_institution.institution_name}" : "Link account" %>
</button>
</div>
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
function initializePlaidLink() {
// In re-auth mode, pass OUR institution UUID through to the exchange endpoint
// so the backend updates exactly the right record — item_id can change and
// two institutions at the same bank can have identical account names/masks.
const reauthInstitutionId = '<%= @reauth_institution&.id %>';
const handler = Plaid.create({
token: '<%= @link_token %>',
onSuccess: (public_token, metadata) => {
const body = { public_token: public_token, institution: metadata.institution };
if (reauthInstitutionId) body.plaid_institution_id = reauthInstitutionId;
fetch('/plaid/exchange_public_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(body)
}).then(response => {
if (response.ok) { window.location.href = '/'; }
else { alert('Linking failed. Please try again.'); }
});
},
onExit: (err, metadata) => { if (err) console.error('Plaid Link exit:', err); }
});
document.getElementById('link-button')
.addEventListener('click', () => handler.open());
// Auto-open in re-auth mode — the user already clicked "fix connection".
if (reauthInstitutionId) handler.open();
}
// Cover both Turbo and non-Turbo page loads.
document.addEventListener('turbo:load', initializePlaidLink, { once: true });
if (document.readyState !== 'loading') initializePlaidLink();
</script>
Gotchas (the hard-won stuff)
- No gem needed — and none available. The
plaidgem is not in the base image and Leo instances can't add gems. TheNet::HTTPmodule above is the whole client; Plaid's API is uniform enough (JSON POST, keys in the body) that onepost_requesthelper covers every endpoint. - The "two accounts at the same bank" trap (this one caused real data crossing).
Plaid's
institution_id(e.g.ins_120013) identifies the bank, not the login. A user with personal + business accounts at the same bank has two Items with the same bank ID — and possibly identical account names and masks. Defenses, in order: (1) during re-auth, pass your own institution UUID from the frontend and match by it directly; (2) when fuzzy-matching by mask+name, always scope the query to the sameinstitution_id_plaid; (3) in the sync job, look accounts up through the institution association, never globally. - Re-auth (update mode) is a different flow, not a re-link. Pass the stored
access_tokentolink/token/create(and omitproducts). On success thepublic_tokenmay be null and theaccess_tokendoes not change — skip the exchange and reuse the stored token. Treating re-auth as a fresh link is how you end up with duplicate institutions. account_idis not stable;mask+nameis. When a user re-links, Plaid issues brand-newaccount_ids (and a newitem_id). Match accounts by mask+name first, then update the storedaccount_id_plaid. This is also why the unique index is on(user_id, mask, name).- Ask for history up front:
days_requested: 730. Plaid defaults to 90 days of backfill and the setting is fixed at Item creation — you cannot raise it later without re-linking. For bookkeeping apps, 90 days is never enough. - Persist the
transactions/synccursor after every page. Thewhile has_moreloop can crash mid-run (Plaid 500, deploy, OOM). Savingnext_cursoreach iteration means the next run resumes where it left off instead of re-processing everything. TheTransaction.exists?(transaction_id_plaid:)guard makes replays harmless anyway. - Webhooks: skip CSRF and auth, and always
head :ok. Plaid's servers have no session and no CSRF token. A non-200 response makes Plaid retry and can eventually get the webhook disabled. Also: webhooks need a publicBASE_URL— they will never arrive on localhost, so keep a manual "Sync now" button as the fallback path. - Handle
modifiedandremoved, not justadded. Pending transactions post a few days later as modified (amount and name can change), and cancelled ones arrive as removed. Only processingaddedleaves phantom pending charges in the ledger. - Amount sign convention: Plaid's
amountis positive for money leaving the account (a purchase) and negative for money coming in (a paycheck) — backwards from what most people expect. Decide once, at ingest, how your app stores it. - Bank logins WILL expire — build the health state machine on day one. OAuth
consents lapse, passwords change, banks force re-login. The
statusenum +record_sync_failure!(which mapsITEM_LOGIN_REQUIRED→needs_reauth) + the webhook handlers (PENDING_EXPIRATION,LOGIN_REPAIRED,ERROR) let the UI show "Reconnect your bank" instead of silently serving stale numbers. - Keep the error-string format stable.
post_requestraises"Plaid error CODE: MESSAGE - DISPLAY"andparse_plaid_errorregexes it back apart to feed the state machine. If you reword one, reword both. - Sandbox → production is a triple change:
PLAID_URL, thePLAID_SECRET(each environment has its own), and production access approval in the Plaid dashboard. Sandbox test login isuser_good/pass_good. After editing.env, recreate the container — a plainrestartdoes not reload env vars. - Backfilling a gap:
transactions/synconly moves forward from its cursor. To re-pull a specific historical window (e.g. "March is missing"), useget_transactions_by_date_range(transactions/get, offset-paginated, 500 per page) in a separate job, and dedup bytransaction_id_plaidon insert.
Files this pattern touches
.env # PLAID_CLIENT_ID / PLAID_SECRET / PLAID_URL / BASE_URL
db/migrate/*_create_plaid_institutions.rb
db/migrate/*_create_plaid_accounts.rb
db/migrate/*_add_plaid_columns_to_transactions.rb
app/models/plaid_institution.rb
app/models/plaid_account.rb
app/services/plaid_service.rb
app/controllers/plaid_controller.rb
app/jobs/plaid_transaction_sync_job.rb
app/views/plaid/new.html.erb
config/routes.rb
How to adapt to your schema
- Already have a transactions/ledger table? Keep it — just add the Plaid columns
from the third migration.
transaction_id_plaid(dedup key) and the two foreign keys are the only ones the sync depends on; the category/merchant columns are nice-to-have. - Only need balances, not transactions? Drop the job, the cursor column, and the
transaction columns. Call
get_accountson demand (or on a schedule) and store snapshots in a smallplaid_account_balancestable (plaid_account_id,balance_date,balance_current). - Single-user internal tool? The
current_userscoping still costs nothing — keep it. It's what makes the mask+name unique index safe. - Different country: change
country_codesincreate_link_tokenand drop theUSDdefaults. - No background job runner? The sync can run inline in
exchange_public_tokenfor small accounts, but the initial 730-day backfill of a busy account takes many pages — keep it in a job if at all possible.