Plaid Webhook Integration Tutorial: 2027 Endpoints
Plaid webhooks are deceptively simple until they aren't. Here's the production-grade integration guide nobody wrote until now.

Plaid's webhook system is the backbone of any serious bank-data integration — and it's also where most teams quietly accumulate technical debt. You get the Hello World working in an afternoon, push to staging, and then spend the next two sprints firefighting missed events, duplicate transaction inserts, and the dreaded ITEM_LOGIN_REQUIRED that surfaces at 11 PM on a Friday.
This guide is the one we wished existed before we built the AtlasForge Financial API. It covers the full lifecycle: registering endpoints, verifying Plaid's JWT signatures correctly, handling every webhook type you'll actually encounter in 2027, and reconciling incoming payloads against your own data store without creating race conditions. We'll use Node.js (TypeScript) for examples, but the logic maps cleanly to any runtime.
Why Plaid Webhooks Deserve More Respect
Plaid processed over 6 billion API calls in Q1 2027, according to figures cited in their developer changelog. A non-trivial fraction of those were webhook deliveries — asynchronous pushes that tell your server something meaningful changed on a user's linked account. Get them wrong and you're either showing stale balances or inserting phantom transactions.
The three failure modes that bite teams hardest:
- Skipping signature verification — treating any POST to
/webhooks/plaidas legitimate, opening a trivial replay-attack surface. - No idempotency keys — processing the same
TRANSACTIONS_SYNCevent twice because your queue retried after a 500. - Ignoring
ITEM_LOGIN_REQUIRED— letting the item go stale silently instead of surfacing re-authentication to the user within minutes.
All three are fixable in a single focused sprint. Let's go.
Step 1 — Register Your Endpoint and Configure Plaid Link
Before a single byte arrives at your server, you need two things in the Plaid Dashboard: a webhook URL per environment and a configured Plaid Link flow that captures the public_token and exchanges it for an access_token + item_id.
Endpoint requirements
- Must be HTTPS with a valid certificate (self-signed certs fail in production).
- Must return HTTP
200within 10 seconds — Plaid will retry up to 7 times with exponential backoff if you don't. - Must handle
POSTrequests withContent-Type: application/json.
Set your webhook URL during item creation by passing webhook in your /link/token/create call:
const response = await plaidClient.linkTokenCreate({
user: { client_user_id: userId },
client_name: "AtlasForge",
products: [Products.Transactions],
country_codes: [CountryCode.Us],
language: "en",
webhook: "https://api.yourdomain.com/webhooks/plaid",
});
You can also update the webhook URL for an existing item via /item/webhook/update — useful when you're migrating environments.
Step 2 — Verify the JWT Signature (Don't Skip This)
As of Plaid's 2024 API update — which remains current through the 2027 endpoint revision — every webhook delivery includes a Plaid-Verification header containing a signed JWT. Verifying it is non-negotiable in a production system.
Plaid uses ES256 (ECDSA with P-256 and SHA-256). The verification flow:
- Extract the JWT from the
Plaid-Verificationheader. - Decode the header to get the
kid(key ID). - Fetch the current signing keys from
/webhook_verification_key/getusing thatkid. - Verify the JWT signature against the key.
- Confirm the JWT
iat(issued-at) is within 5 minutes of your server clock. - Confirm the JWT
request_body_sha256matches a SHA-256 hash of the raw request body.
import * as jose from "jose";
import crypto from "crypto";
async function verifyPlaidWebhook(
rawBody: Buffer,
token: string,
plaidClient: PlaidApi
): Promise<boolean> {
const decoded = jose.decodeProtectedHeader(token);
const kid = decoded.kid as string;
const keyResponse = await plaidClient.webhookVerificationKeyGet({
key_id: kid,
});
const jwk = keyResponse.data.key;
const publicKey = await jose.importJWK(jwk, "ES256");
const { payload } = await jose.jwtVerify(token, publicKey, {
algorithms: ["ES256"],
clockTolerance: 300,
});
const bodyHash = crypto
.createHash("sha256")
.update(rawBody)
.digest("hex");
return payload["request_body_sha256"] === bodyHash;
}
Cache those verification keys. Plaid rotates signing keys periodically. Cache by
kidwith a TTL of around 1 hour, but always fall back to a fresh fetch if verification fails — you may have a stale key, not a forged request.
If verification fails, return HTTP 400 and log the raw header for incident review. Do not process the payload.
Step 3 — Parse and Route Webhook Types
Once verified, you're dealing with a JSON payload that always has webhook_type and webhook_code. The combinations you'll see most in a transactions-focused integration:
Transactions webhooks (webhook_type: TRANSACTIONS)
SYNC_UPDATES_AVAILABLE— new data ready; call/transactions/syncwith your storedcursor.INITIAL_UPDATE— historical transactions loaded after item creation.HISTORICAL_UPDATE— up to 24 months of history available.
Item webhooks (webhook_type: ITEM)
ITEM_LOGIN_REQUIRED— user must re-authenticate via Plaid Link update mode.PENDING_EXPIRATION— credential expiration coming in N days.ERROR— catch-all for item-level errors; checkerror.error_code.
Assets, Auth, and Liabilities webhooks follow the same shape; route by webhook_type first, then webhook_code.
A clean router in TypeScript:
type WebhookHandler = (payload: PlaidWebhookPayload) => Promise<void>;
const handlers: Record<string, Record<string, WebhookHandler>> = {
TRANSACTIONS: {
SYNC_UPDATES_AVAILABLE: handleSyncUpdates,
INITIAL_UPDATE: handleSyncUpdates,
HISTORICAL_UPDATE: handleSyncUpdates,
},
ITEM: {
ITEM_LOGIN_REQUIRED: handleLoginRequired,
PENDING_EXPIRATION: handlePendingExpiration,
ERROR: handleItemError,
},
};
async function routeWebhook(payload: PlaidWebhookPayload): Promise<void> {
const handler = handlers[payload.webhook_type]?.[payload.webhook_code];
if (!handler) {
logger.warn(`Unhandled webhook: ${payload.webhook_type}/${payload.webhook_code}`);
return;
}
await handler(payload);
}
Step 4 — Handling ITEM_LOGIN_REQUIRED Correctly
This is the webhook that separates production-grade integrations from demos. When Plaid sends ITEM_LOGIN_REQUIRED, the user's credentials have expired or MFA has changed, and no new data will arrive for that item until they re-authenticate.
Your response should be immediate and user-facing:
- Look up the user(s) associated with the
item_idin your database. - Mark the item as
requires_reauthin your store. - Enqueue a notification (email, push, or in-app) within 5 minutes.
- Generate a new Link token in update mode and surface it the next time the user opens your app.
async function handleLoginRequired(payload: PlaidWebhookPayload): Promise<void> {
const { item_id } = payload;
await db.items.update(
{ status: "requires_reauth" },
{ where: { plaid_item_id: item_id } }
);
const user = await db.users.findByItemId(item_id);
await notificationQueue.add("reauth_required", {
userId: user.id,
itemId: item_id,
});
logger.info(`ITEM_LOGIN_REQUIRED queued reauth for user ${user.id}`);
}
For the re-authentication Link token, use /link/token/create with access_token instead of products. This launches Link in update mode with the existing institution pre-selected.
According to Plaid's official documentation on item errors, ITEM_LOGIN_REQUIRED is classified under the ITEM_ERROR family and should be treated as a first-class user experience event, not a background error.
Step 5 — Reconciling with Your Own Data Store
Webhooks are at-least-once delivered. Plaid's retry logic means you will receive duplicates — plan for it from day one.
The safest pattern is idempotent upserts keyed on Plaid's stable transaction IDs:
async function handleSyncUpdates(payload: PlaidWebhookPayload): Promise<void> {
const { item_id } = payload;
const item = await db.items.findByPlaidItemId(item_id);
let cursor = item.transactions_cursor ?? undefined;
let hasMore = true;
while (hasMore) {
const syncResponse = await plaidClient.transactionsSync({
access_token: item.access_token,
cursor,
count: 500,
});
const { added, modified, removed, next_cursor } = syncResponse.data;
await db.transaction(async (trx) => {
// Upsert added and modified
await trx.transactions.bulkUpsert(
[...added, ...modified].map(normalizePlaidTransaction),
{ conflictTarget: "plaid_transaction_id" }
);
// Soft-delete removed
if (removed.length) {
const removedIds = removed.map((r) => r.transaction_id);
await trx.transactions.softDelete({ plaid_transaction_id: removedIds });
}
// Persist cursor atomically
await trx.items.update(
{ transactions_cursor: next_cursor },
{ where: { plaid_item_id: item_id } }
);
});
cursor = next_cursor;
hasMore = syncResponse.data.has_more;
}
}
Critical detail: save the cursor inside the same database transaction as your upserts. If you save the cursor after the upserts in a separate call and your process crashes between the two, you'll miss transactions permanently.
What to store
At minimum, persist these fields from each Plaid transaction object:
transaction_id(your idempotency key)account_idamount(Plaid signs debits as positive — normalize to your convention)dateandauthorized_date(they differ; useauthorized_datefor display)nameandmerchant_namepersonal_finance_category.primaryand.detailed(the 2024+ taxonomy)pendingflagiso_currency_code
Step 6 — Operational Observability
Your webhook handler isn't done when the code ships. You need:
- Structured logs on every verified event:
webhook_type,webhook_code,item_id, processing duration in ms. - Dead-letter queue for any webhook that throws an unhandled exception — inspect it weekly.
- Prometheus counter (or your equivalent) for
webhook_verification_failures— a spike means either a Plaid key rotation you missed or someone probing your endpoint. - Alerting on
ITEM_LOGIN_REQUIREDvolume — a sudden spike can indicate a financial institution changing their OAuth flow, which you'll need to communicate to affected users at scale.
Plaid's webhook dashboard also shows delivery history and lets you replay failed events — indispensable during debugging and worth bookmarking for your on-call runbook.
For reference, the Federal Financial Institutions Examination Council's guidance on API security and the CFPB's open banking rulemaking both treat webhook integrity — signature verification and audit trails — as baseline expectations for financial data aggregators operating in the US market as of 2027.
Putting It All Together
A production Plaid webhook integration is really four systems working in concert:
- Ingress layer — HTTPS endpoint that verifies the JWT signature and returns
200fast. - Queue — decouple ingress from processing; your endpoint should enqueue and acknowledge, not do database work synchronously.
- Worker — consumes the queue, routes by type/code, and runs idempotent upserts.
- Notification system — reacts to
ITEM_LOGIN_REQUIREDandPENDING_EXPIRATIONwith timely user-facing prompts.
Skip any of these four and you'll eventually have a production incident. Build all four in the right order — ingress, then queue, then worker, then notifications — and you have a Plaid integration that handles institutional flakiness, key rotations, and credential expirations without waking anyone up.
If you're building a fintech product on top of this stack and want a reference implementation that's already solved these problems, the AtlasForge Financial API ships with a pre-built webhook adapter layer, idempotent transaction sync, and first-class ITEM_LOGIN_REQUIRED handling baked in. It's the integration infrastructure layer so you can focus on your product — not Plaid plumbing. Explore the full developer docs at /developers or see how it powers the real-time spending intelligence in Safe to Spend 365.
Further reading
Ready to build on AtlasForge?
Get sandbox API keys in 60 seconds — or install the Safe to Spend 365 app.
