PlaidEx implements Plaid's /transactions/sync cursor-based endpoint with
full OTP supervision, durable cursor management, and correct handling of
every documented edge case.
Why cursor-based sync?
Plaid's older /transactions/get endpoint retrieves transactions by date range
and has no concept of "what changed since last time." Every poll returns
a full dataset, making it impossible to detect deletions efficiently.
The /transactions/sync endpoint was designed for ongoing synchronization:
- Returns
added,modified, andremovedarrays — only what changed - Cursor represents your position in Plaid's transaction event log
has_more: truemeans more pages are ready — fetch immediatelyhas_more: falsemeans you're caught up — sleep until next poll
Starting a sync worker
{:ok, _pid} = PlaidEx.start_transaction_sync(access_token,
handler: fn %PlaidEx.Schemas.TransactionSyncPage{} = page ->
# Process added and modified (upsert)
MyApp.Transactions.upsert_batch(page.added)
MyApp.Transactions.upsert_batch(page.modified)
# Process removals (delete)
removed_ids = Enum.map(page.removed, & &1.transaction_id)
MyApp.Transactions.delete_batch(removed_ids)
# Return :ok on success, {:error, reason} to retry the same page
:ok
end,
tenant_id: "acme_corp", # optional, for multi-tenant
poll_interval_ms: 30_000 # optional, default: 30s
)The handler contract
Your handler function MUST:
- Return
:okon success — the cursor advances - Return
{:error, reason}on failure — the same page is retried - Be idempotent — it may be called twice for the same page (explained below)
- Not raise — exceptions are caught and treated as
{:error, ...}
Why idempotency matters
PlaidEx commits the cursor before calling your handler. This ensures:
- On handler success: cursor advances, data processed ✓
- On handler crash after cursor commit: same page re-delivered on restart ← your handler must handle duplicates
- On handler crash before cursor commit: impossible (cursor already saved)
- On process crash before cursor commit: restart from previous cursor ← some transactions may be re-delivered
The correct approach is upsert semantics using plaid_transaction_id as
the unique key:
handler: fn page ->
Repo.transaction(fn ->
# Upsert — safe to call multiple times with same data
Enum.each(page.added ++ page.modified, fn tx ->
Repo.insert!(%MyTransaction{
plaid_transaction_id: tx.transaction_id,
# ... other fields
},
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: :plaid_transaction_id
)
end)
Enum.each(page.removed, fn r ->
Repo.delete_all(from t in MyTransaction,
where: t.plaid_transaction_id == ^r.transaction_id
)
end)
end)
:ok
endCursor persistence (critical for production)
The default CursorStore uses ETS — cursors are lost on application restart.
This means a restart triggers a full historical re-sync.
For production, implement the CursorStore.Behaviour with a database backend:
defmodule MyApp.PlaidCursorStore do
@behaviour PlaidEx.Sync.CursorStore.Behaviour
alias MyApp.Repo
alias MyApp.PlaidItem
@impl true
def get(access_token) do
case Repo.get_by(PlaidItem, access_token_hash: hash(access_token)) do
nil -> nil
item -> item.sync_cursor
end
end
@impl true
def put(access_token, cursor) do
Repo.update_all(
from(i in PlaidItem, where: i.access_token_hash == ^hash(access_token)),
set: [sync_cursor: cursor, cursor_updated_at: DateTime.utc_now()]
)
:ok
end
@impl true
def delete(access_token) do
Repo.update_all(
from(i in PlaidItem, where: i.access_token_hash == ^hash(access_token)),
set: [sync_cursor: nil, cursor_updated_at: DateTime.utc_now()]
)
:ok
end
defp hash(access_token) do
:crypto.hash(:sha256, access_token) |> Base.encode16(case: :lower)
end
end
# config/config.exs
config :plaid_ex, cursor_store: MyApp.PlaidCursorStoreHandling sync worker lifecycle
Check if a worker is running
case PlaidEx.transaction_sync_status(access_token) do
{:ok, status} ->
IO.inspect(status)
# %{
# paused: false,
# pause_reason: nil,
# consecutive_errors: 0,
# total_pages_synced: 142,
# total_transactions_added: 8743,
# last_sync_at: ~U[2024-01-15 10:30:00Z],
# has_cursor: true
# }
{:error, :not_found} ->
# No worker running for this access_token
:ok
endTrigger an immediate sync
When you receive a SYNC_UPDATES_AVAILABLE webhook, trigger an immediate
sync rather than waiting for the next poll cycle:
def on_transactions_sync(%{item_id: item_id}) do
case MyApp.Items.get_access_token(item_id) do
{:ok, access_token} ->
PlaidEx.trigger_transaction_sync(access_token)
{:error, :not_found} ->
:ok
end
:ok
endPause and resume
When a user revokes access or you detect suspicious activity:
# Pause
PlaidEx.Sync.TransactionSync.pause(access_token, :user_revoked)
# Resume (e.g., after user re-authenticates)
PlaidEx.Sync.TransactionSync.resume(access_token)Stop a worker
When an item is removed:
def remove_plaid_item(item) do
access_token = decrypt!(item.access_token)
# Stop the sync worker first
PlaidEx.stop_transaction_sync(access_token)
# Remove from Plaid (revokes access)
PlaidEx.API.Items.remove(config, access_token)
# Remove from your database
Repo.delete!(item)
endEdge cases PlaidEx handles automatically
ITEM_LOGIN_REQUIRED
The worker pauses itself and emits a telemetry event. You do NOT need to handle this in your handler — your webhook handler does:
def on_item_error(%{error: %{"error_code" => "ITEM_LOGIN_REQUIRED"}, item_id: item_id}) do
# Worker is already paused. Send user through Link update mode.
MyApp.Notifications.notify_reconnect(item_id)
:ok
endAfter the user reconnects, resume the worker:
PlaidEx.Sync.TransactionSync.resume(access_token)TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION
Plaid mutated the transaction log while you were paginating. The cursor is
invalidated. PlaidEx resets the cursor to nil and starts a fresh sync —
this is the correct documented behavior. Your handler will re-receive all
historical transactions. Ensure it uses upsert semantics.
PRODUCT_NOT_READY
The item was just created and Plaid hasn't finished loading historical data yet. PlaidEx retries automatically with backoff.
Institution outages
Plaid returns INSTITUTION_DOWN or INSTITUTION_NOT_RESPONDING. PlaidEx
retries with exponential backoff:
Attempt 1 → fail → sleep 5s
Attempt 2 → fail → sleep 10s
Attempt 3 → fail → sleep 20s
Attempt 4 → fail → sleep 40s
...max 5 minutesThe circuit breaker also opens after 5 consecutive failures, protecting the rest of your system from being slowed by a single institution's issues.
High-throughput: Broadway pipeline
For platforms ingesting hundreds of items simultaneously, use the Broadway pipeline instead of individual workers:
defmodule MyApp.TransactionPipeline do
use PlaidEx.Sync.BroadwayPipeline
@impl true
def handle_transaction(%PlaidEx.Schemas.Transaction{} = tx, _context) do
case MyApp.Transactions.upsert(tx) do
{:ok, _} -> :ok
{:error, changeset} -> {:error, changeset}
end
end
@impl true
def handle_removed(transaction_id, _context) do
MyApp.Transactions.delete(transaction_id)
:ok
end
end
# In your application supervisor:
children = [
{MyApp.TransactionPipeline,
access_tokens: MyApp.Items.all_access_tokens(),
config: PlaidEx.config(),
concurrency: 20,
batch_size: 100,
batch_timeout_ms: 2_000
}
]Monitoring sync health
Set up alerts on these telemetry events:
# Alert when a worker enters reauth state
:telemetry.attach("sync_reauth_alert",
[:plaid_ex, :sync, :reauth_required],
fn _event, _measurements, %{tenant_id: tenant_id}, _ ->
MyApp.Alerts.warn("Plaid item requires reauth", tenant_id: tenant_id)
end,
nil
)
# Track sync lag (time since last successful page)
:telemetry.attach("sync_page_tracker",
[:plaid_ex, :sync, :page],
fn _event, %{added: added}, %{tenant_id: tenant_id}, _ ->
MyMetrics.gauge("plaid.sync.transactions_per_page", added,
tags: [tenant_id: tenant_id]
)
end,
nil
)Database schema recommendation
# Ecto migration
create table(:plaid_items) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :item_id, :string, null: false
add :access_token_encrypted, :binary, null: false # encrypt at rest
add :institution_id, :string
add :sync_cursor, :text # Plaid transaction cursor
add :cursor_updated_at, :utc_datetime_usec
add :status, :string, default: "active" # active|reauth_required|error|removed
add :error_code, :string
add :last_sync_at, :utc_datetime_usec
add :webhook_url, :string
timestamps()
end
create unique_index(:plaid_items, [:item_id])
create index(:plaid_items, [:user_id])
create index(:plaid_items, [:status])
create table(:plaid_transactions) do
add :item_id, references(:plaid_items, on_delete: :delete_all), null: false
add :plaid_transaction_id, :string, null: false # Plaid's transaction ID
add :account_id, :string, null: false
add :amount, :decimal, null: false
add :iso_currency_code, :string
add :date, :date, null: false
add :datetime, :utc_datetime_usec
add :name, :string
add :merchant_name, :string
add :category, {:array, :string}
add :personal_finance_category, :map
add :pending, :boolean, default: false
add :logo_url, :string
add :website, :string
add :payment_channel, :string
timestamps()
end
create unique_index(:plaid_transactions, [:plaid_transaction_id])
create index(:plaid_transactions, [:item_id, :date])
create index(:plaid_transactions, [:account_id])