Implement Plaid's /transactions/sync endpoint for cursor-based transaction pagination. Covers added/modified/removed handling, cursor persistence, deduplication, and the has_more loop. Use when the user needs to fetch or sync transactions.
Use this skill when the user:
/transactions/sync (the recommended approach)/transactions/get endpointtransactions productUnderstand the sync model. /transactions/sync returns incremental updates since your last cursor. Each response contains:
added - new transactionsmodified - updated transactions (amount, category, name changes)removed - deleted transactions (by transaction_id)next_cursor - bookmark for the next callhas_more - whether more pages remainImplement the sync loop. Always loop until has_more is false:
import { PlaidApi } from "plaid";
async function syncTransactions(
client: PlaidApi,
accessToken: string,
cursor: string | undefined
) {
const allAdded: Transaction[] = [];
const allModified: Transaction[] = [];
const allRemoved: RemovedTransaction[] = [];
let hasMore = true;
let nextCursor = cursor ?? "";
while (hasMore) {
const response = await client.transactionsSync({
access_token: accessToken,
cursor: nextCursor,
count: 500, // max per page
});
allAdded.push(...response.data.added);
allModified.push(...response.data.modified);
allRemoved.push(...response.data.removed);
hasMore = response.data.has_more;
nextCursor = response.data.next_cursor;
}
return { added: allAdded, modified: allModified, removed: allRemoved, cursor: nextCursor };
}
Persist the cursor. After processing all pages, save next_cursor to your database. On the next sync call, pass it back. If the cursor is lost, pass an empty string to get a full history replay.
// Save after successful processing
await db.items.update({
where: { item_id: itemId },
data: { transaction_cursor: nextCursor },
});
Apply changes to your local store:
// INSERT new transactions
for (const txn of added) {
await db.transactions.upsert({
where: { plaid_transaction_id: txn.transaction_id },
create: {
plaid_transaction_id: txn.transaction_id,
amount: txn.amount,
date: txn.date,
name: txn.name,
merchant_name: txn.merchant_name,
category: txn.personal_finance_category?.primary,
detailed_category: txn.personal_finance_category?.detailed,
pending: txn.pending,
},
update: { /* same fields for modified */ },
});
}
// DELETE removed transactions
for (const removed of allRemoved) {
await db.transactions.delete({
where: { plaid_transaction_id: removed.transaction_id },
});
}
Handle pending transactions. Plaid sends pending transactions that later resolve to posted. The posted version has a different transaction_id. Use pending_transaction_id on the posted transaction to link them:
if (txn.pending_transaction_id) {
await db.transactions.delete({
where: { plaid_transaction_id: txn.pending_transaction_id },
});
}
Trigger syncs via webhooks. Don't poll on a timer. Listen for SYNC_UPDATES_AVAILABLE webhooks:
// In your webhook handler
if (webhookCode === "SYNC_UPDATES_AVAILABLE") {
const item = await db.items.findUnique({ where: { item_id: itemId } });
await syncTransactions(client, item.access_token, item.transaction_cursor);
}
User: "I need to sync bank transactions from Plaid into my Postgres database. I'm using Node.js and Prisma."
Agent:
has_more paginationplaid_transaction_id as unique keypending_transaction_idSYNC_UPDATES_AVAILABLE| Step | MCP Tool | Description |
|---|---|---|
| Run a sync | plaid_syncTransactions | Execute /transactions/sync with automatic cursor management |
| Force refresh | plaid_refreshTransactions | Trigger a transaction refresh for stale data |
| Fire test webhook | plaid_fireSandboxWebhook | Fire SYNC_UPDATES_AVAILABLE to test your handler |
has_more - a single call may not return all updates. Always loop until has_more is false. Failing to do so causes silent data loss.modified transactions - Plaid can retroactively change transaction amounts, names, and categories. If you only handle added, your data drifts from reality.removed transactions - banks reverse or delete transactions. If you don't process removals, your balances won't match.SYNC_UPDATES_AVAILABLE webhooks to trigger syncs.pending_transaction_id to deduplicate.