SnapTrade Webhook Dedupe and Backpressure Plan
Context
On April 29, 2026, production saw a burst of SnapTrade-related background work. The logs showed many concurrent calls to:/api/snaptrade/webhook/api/internal/process-snaptrade-holdings-sync/api/internal/process-snaptrade-transactions-sync/api/cron/process-stale-snaptrade
- SnapTrade sent multiple webhook events for accounts/connections.
- The webhook handler created Supabase job rows and published QStash jobs.
- QStash delivered many long-running Next.js workers concurrently.
- Workers retried SnapTrade
429and gateway errors by sleeping inside Vercel functions. - Long-lived workers saturated provider capacity and contributed to Vercel/Supabase timeouts on user-facing routes.
Current Implementation
The fix adds Redis-backed guards around the existing QStash flow. New helper:lib/snaptrade-webhook-guards.ts
snaptrade:webhook:{webhookId}: exact webhook dedupe, 24 hour TTL.snaptrade:account:queued:{workType}:{accountId}: one queued job per account/work type, 30 minute TTL.snaptrade:account:lock:{workType}:{accountId}: one active worker per account/work type, 30 minute TTL.
app/api/snaptrade/webhook/route.tsapp/api/internal/process-snaptrade-holdings-sync/route.tsapp/api/internal/process-snaptrade-transactions-sync/route.tsapp/api/cron/schedule-stale-snaptrade/route.tssupabase/migrations/20260501_add_snaptrade_webhook_processed_unique_index.sql
Webhook Intake
The SnapTrade webhook route now:- Validates the webhook secret.
- Extracts the SnapTrade webhook id, including the legacy typo field
webookId. - Claims
snaptrade:webhook:{webhookId}in Redis usingSET NX EX. - Acknowledges duplicate webhook ids without queueing additional work.
- Claims
snaptrade:account:queued:{workType}:{accountId}before creating another holdings or transactions job. - Coalesces duplicate holdings/transactions webhooks for the same account while a job is already queued.
- Awaits QStash publishing for background jobs.
Worker Execution
The holdings and transactions workers now:- Verifies the QStash signature.
- Claims
snaptrade:account:lock:{workType}:{accountId}before doing SnapTrade API work. - Skips duplicate delivered jobs for the same account.
- Releases both active and queued Redis keys in
finally.
Durable Backstop
Redis is the hot-path guard, but the database now also enforces webhook intake idempotency with:snaptrade_webhook_events_queued_webhook_id_unique
webhook_id IS NOT NULLwebhook_id !~ '^error_'processed IS TRUE
processed column is used by webhook intake to mean “accepted and queued” and by workers to mean “finished”, so historical dashboards that count processed = true should treat the migration as a duplicate cleanup.
The migration uses regular CREATE UNIQUE INDEX for standard Supabase migration compatibility. If snaptrade_webhook_events is large in production, apply the equivalent index concurrently outside a transaction during a maintenance window.
QStash Backpressure
SnapTrade QStash flow control was reduced from:claimSnapTradeAccountLock) prevent same-account
concurrency, so 100 across DIFFERENT accounts is safe and gives
recovery throughput. Going lower (e.g., 5/5) would starve the system
during a backlog because only 5 different accounts could sync at once.
Why Redis Is Needed
Supabase job rows are still useful as the durable audit trail, but they are not ideal for hot-path webhook coordination. Redis is better for:- Atomic
SET NXclaims. - Short-lived dedupe keys.
- Fast per-account coalescing.
- Expiring stale locks automatically.
- Avoiding extra Supabase load during webhook bursts.
Remaining Gaps
This implementation covers holdings sync first because holdings workers were the clearest pressure point in the incident logs. Still recommended:- Replace in-function SnapTrade retry sleeps with delayed QStash requeue.
- Add a Redis provider cooldown key after SnapTrade
429, for examplesnaptrade:provider:cooldown. - Add dashboard/log metrics for:
- Duplicate webhook ids skipped.
- Account jobs coalesced.
- Active-lock skips.
- QStash publish failures.
- SnapTrade 429s.
- Consider active-lock refresh inside long-running workers if delayed QStash requeue is deferred and production jobs approach the 30 minute TTL.
Operational Knobs
Current settings:- SnapTrade QStash
parallelism:100 - SnapTrade QStash
rate:100 - Webhook dedupe TTL:
24h - Account queued TTL:
30m - Account active-lock TTL:
30m
429 rate climbs again, the first knob to turn down is
parallelism (and rate together) — step to 75/75, then 50/50,
etc. Per-account locks already prevent same-account concurrency so any
positive value is safe; lower values trade recovery throughput for
provider gentleness.
Do not jump back to 200. SnapTrade explicitly told us they were
seeing 429s from us at that level (incident postmortem 2026-05-01).
Verification
After implementation:npm run lintpassed with existing repository warnings.npx tsc --noEmit --pretty falsepassed.
- Watch Vercel logs for
Duplicate webhook,coalescing webhook, andalready active for account. - Watch SnapTrade
429frequency. - Watch Vercel function duration for SnapTrade workers.
- Watch user-facing route latency during the next scheduled stale SnapTrade run.
- Confirm QStash retries are not building an unbounded backlog.