On this page
Outline
Introduction (100 words)
- The nightmare scenario: customer gets charged twice because your webhook handler processed a retry
- Why webhooks retry (network failures, timeouts, crashes)
- Why this matters MORE with payment processors (Stripe, PayPal, etc.)
- The simple solution: idempotency keys and deduplication logic
- What you’ll learn: production-ready pattern you can copy/paste
The Problem: Webhook Retries Are Normal (200 words)
- Stripe (and most webhooks) retry failed deliveries automatically
- Retry schedule: Stripe retries with exponential backoff for up to 3 days in live mode (schedules vary by provider)
- Common failure scenarios:
- Your n8n server was restarting
- Database connection timeout
- API rate limit hit mid-workflow
- Network blip during response
- The webhook sender CAN’T know if you processed the event before failing
- Result: same event delivered 2+ times
- Without deduplication: same action happens twice (charge customer, send email, create record)
Why Simple “Check if Exists” Isn’t Enough (150 words)
- Race condition: two webhook deliveries arrive 100ms apart
- Both check database → both see “doesn’t exist” → both process
- This is REALLY common with high-volume webhooks
- Database-level uniqueness constraints help but don’t prevent workflow execution
- Need atomic “check-and-set” operation
The Idempotency Pattern (300 words)
What is Idempotency?
- Computer science principle: operation can be repeated safely
- Same input → same result, no duplicate side effects
- Payment example: charging customer twice vs recording charge once and returning same receipt
How It Works in n8n:
- Extract unique event ID from webhook payload (Stripe:
idfield) - Atomic check: Try to INSERT event ID into deduplication table with unique constraint
- If INSERT succeeds → first time seeing this event → process normally
- If INSERT fails (duplicate key error) → already processed → return 200 OK and exit
- Return success response AFTER processing completes
Key Implementation Details:
- Use database unique constraint, NOT application-level check
- Store: event_id, processed_at timestamp, workflow_execution_id (for debugging)
- Add TTL/cleanup: delete records older than 90 days (Stripe’s event retention)
- Return 200 OK even for duplicates (tells Stripe to stop retrying)
n8n Workflow Implementation (400 words)
Workflow Structure:
Webhook Trigger
↓
Extract Event ID
↓
Postgres INSERT (with ON CONFLICT DO NOTHING)
↓
IF: Check if inserted (row count > 0)
↓ YES (first time)
Process Event (Stripe API, update customer, send email, etc.)
↓
Update dedupe record with status="completed"
↓ NO (duplicate)
Skip processing
↓
Respond 200 OK
Step-by-Step Setup:
1. Create Deduplication Table (Postgres):
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
source VARCHAR(50) NOT NULL, -- 'stripe', 'github', etc.
processed_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'processing',
workflow_execution_id VARCHAR(255),
payload JSONB
);
CREATE INDEX idx_processed_at ON webhook_events(processed_at);
2. Webhook Node Configuration:
- Path:
/webhooks/stripe - Method: POST
- Response: “Webhook Received” (we’ll customize later)
- Authentication: Stripe signature verification (separate guide)
3. Extract Event ID (Code Node):
const eventId = $input.item.json.id; // Stripe event ID
const source = 'stripe';
return {
eventId,
source,
payload: $input.item.json
};
4. Dedupe Check (Postgres Node):
- Operation:
Execute Query - Query:
INSERT INTO webhook_events (event_id, source, payload, workflow_execution_id)
VALUES (
'{{ $json.eventId }}',
'{{ $json.source }}',
'{{ $json.payload }}'::jsonb,
'{{ $execution.id }}'
)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
- Continue on Fail: YES (important!)
5. IF Node:
- Condition:
{{ $json.event_id !== undefined }} - True = new event, False = duplicate
6. Process Event (True Branch):
- Your actual business logic here (charge customer, update DB, etc.)
- End with Postgres update:
UPDATE webhook_events
SET status = 'completed'
WHERE event_id = '{{ $('Extract Event ID').item.json.eventId }}';
7. Respond (both branches merge here):
- Respond to Webhook node
- Status Code: 200
- Body:
{"received": true}
Testing the Pattern (200 words)
Manual Duplicate Test:
- Trigger webhook from Stripe dashboard
- Check n8n execution → should process normally
- Use Stripe dashboard “Resend Webhook” button
- Check n8n → should skip processing but return 200
- Verify in Postgres: only 1 record with status=“completed”
Load Testing:
- Use tool like Artillery or k6 to send 100 concurrent webhook POSTs with same event ID
- Verify only 1 record created
- Check for any race condition failures
Monitoring:
- Count duplicate events daily:
SELECT COUNT(*) FROM webhook_events WHERE ... - Alert if duplicate rate > 5% (indicates wider issues)
- Track processing failures vs duplicates separately
Alternative Approaches (150 words)
Redis-based deduplication:
- Faster than Postgres for high-volume
- Use
SETNX(set if not exists) command - TTL built-in (auto-expire old keys)
- Trade-off: less durable (data loss if Redis crashes)
n8n’s built-in deduplication:
- Some trigger nodes have deduplication (Webhook doesn’t as of 2026)
- Not suitable for cross-workflow deduplication
- Check node docs for availability
Application-level locks:
- Use distributed locks (Redlock, database locks)
- More complex to implement correctly
- Usually overkill for webhook use case
Common Mistakes (150 words)
❌ Returning 4xx/5xx for duplicates (triggers more retries) ❌ Checking existence without unique constraint (race conditions) ❌ Processing before deduplication check ❌ Not handling DB connection failures gracefully ❌ Forgetting to clean up old records (table grows forever) ❌ Using workflow execution ID as dedupe key (same event can trigger multiple workflows)
✅ Always use provider’s event ID as dedupe key ✅ Return 200 OK for duplicates ✅ Atomic database operations ✅ Cleanup job for old records ✅ Monitor duplicate rates
Production Checklist (100 words)
- Dedupe table created with unique constraint
- Event ID extracted from webhook payload
- INSERT with ON CONFLICT before processing
- Both branches return 200 OK
- Cleanup job scheduled (weekly/monthly)
- Monitoring dashboard tracking duplicate rate
- Load tested with concurrent requests
- Alerts for processing failures
- Documentation updated with webhook patterns
- Team trained on debugging duplicate events
Conclusion (100 words)
- Webhook idempotency isn’t optional for production systems
- The pattern is simple: atomic check, process once, always return 200
- 30 minutes to implement, prevents expensive mistakes forever
- Especially critical for payment processors (Stripe, PayPal)
- Same pattern works for ANY webhook provider (GitHub, Shopify, etc.)
- n8n makes it easy with Postgres node and conditional logic
- Copy this pattern into your workflow template library
Content Notes
- Include full n8n workflow JSON export (downloadable)
- Screenshot of workflow in n8n editor
- Diagram: normal flow vs duplicate flow side-by-side
- Code snippets for Postgres, Redis, and MongoDB variants
- Link to Stripe webhook documentation
- Mention webhook signature verification (separate security concern)
- Real-world incident story (anonymized): “How we accidentally charged 50 customers twice”
SEO Strategy
- Primary: “n8n webhook idempotency”
- Secondary: “prevent duplicate webhook processing”, “Stripe webhook deduplication”, “n8n webhook best practices”
- Long-tail: “why am I getting duplicate Stripe charges in n8n”
- Target: devs implementing payment webhooks, n8n users in production
Promotion
- Post in n8n community (reply to the original thread)
- r/n8n, r/webdev with title “PSA: Don’t process webhooks without idempotency”
- Twitter: “Just saw someone get hit with duplicate Stripe charges from webhook retries. Here’s the 5-line fix:”
- HN: “Show HN: Simple pattern to prevent duplicate webhook processing”
Related reading
Keep learning
Step-by-step n8n tutorials, and free templates you can import and adapt.
Browse tutorials →Browse templates