Payment Webhook Best Practices
A comprehensive guide to implementing reliable webhook handlers for payment integrations. Covers idempotency, signature verification, retry handling, event ordering, dead letter queues, testing strategies, and a side-by-side comparison of Stripe, Plaid, and Adyen webhook patterns.
Idempotency
Payment providers guarantee at-least-once delivery, meaning your handler may receive the same event multiple times. Every webhook handler must be idempotent -- processing the same event twice should have the same effect as processing it once.
Implementation approaches:
- Event ID deduplication: Store processed event IDs (Stripe's
evt_xxx, Adyen'spspReference) in a database with a unique constraint. Skip events that already exist. - State-based processing: Check the current state of your resource before applying changes. If the order is already marked as paid, skip the payment_intent.succeeded event.
- Database transactions: Record the event ID and apply the state change in the same database transaction to avoid race conditions between duplicate deliveries.
Signature Verification (HMAC)
Always verify webhook signatures before processing. This prevents attackers from sending forged events to your endpoint. Each provider uses a different signature scheme:
- Stripe: Computes HMAC-SHA256 over
timestamp.raw_bodyusing your webhook signing secret. TheStripe-Signatureheader contains the timestamp (t) and signature (v1). Check that the timestamp is within your tolerance window (e.g., 5 minutes) to prevent replay attacks. - Plaid: Signs the body as a JWT using a private key. Verify the JWT in the
Plaid-Verificationheader against the public key retrieved from/webhook_verification_key/get. Cache the JWK and refresh on verification failure. - Adyen: Computes HMAC-SHA256 over a concatenation of specific notification fields using a shared HMAC key configured in the Customer Area. The signature appears in the
additionalData.hmacSignaturefield.
Use the raw request body (not a parsed/re-serialized version) for signature computation. JSON re-serialization can change field ordering or whitespace, causing signature mismatches.
Retry Policies
When your endpoint returns a non-success response (or times out), providers will retry. Design your handler with retries in mind:
- Return 200 quickly: Acknowledge receipt immediately, then process the event asynchronously (via a message queue or background job). Most providers timeout after 5-30 seconds.
- Separate receipt from processing: Write the raw event to a durable queue (SQS, Redis Streams, Postgres) and return 200. A separate worker processes the event. If processing fails, you can retry from your own queue without depending on the provider's retry schedule.
- Monitor retry counts: Track how often events are being retried. High retry rates indicate handler failures or timeout issues.
Event Ordering
No major payment provider guarantees event delivery order. Events may arrive out of sequence, especially during high throughput or retries. Strategies for handling this:
- Fetch current state: On receiving an event, call the API to get the current object state rather than relying solely on the event payload. This ensures you always act on the latest data.
- Use timestamps: Compare the event's created timestamp against the last-processed timestamp for that resource. Skip events older than what you have already processed.
- Design for eventual consistency: Accept that your system may temporarily be out of sync with the payment provider. Design state machines that converge to the correct state regardless of event order.
Dead Letter Queues
When webhook processing fails permanently (bad data, unrecoverable error, missing resource), move the event to a dead letter queue (DLQ) rather than retrying indefinitely. This keeps your processing pipeline healthy while preserving the failed event for investigation.
- Define a max retry count: After N processing failures (e.g., 5), move the event to the DLQ.
- Alert on DLQ depth: Monitor the DLQ size and alert when it grows, indicating a systemic processing issue.
- Preserve full context: Store the original event payload, error details, retry count, and timestamps in the DLQ entry.
- Build a replay mechanism: Allow operators to inspect DLQ entries, fix the underlying issue, and replay events back through the handler.
Testing Webhooks
Test your webhook handlers against realistic scenarios before going to production:
- Local forwarding: Use Stripe CLI (
stripe listen --forward-to), ngrok, or localtunnel to forward live test events to your local environment. - Sandbox events: Plaid provides
/sandbox/item/fire_webhookto trigger specific webhook types on demand. - Duplicate delivery: Send the same event twice and verify your handler processes it only once.
- Out-of-order delivery: Send events in reverse order (e.g., succeeded before created) and verify your system converges to the correct state.
- Signature failure: Send a request with an invalid signature and verify your handler rejects it with a 401 or 403.
- Timeout simulation: Test what happens when your handler takes longer than the provider's timeout window.
Provider Comparison
| Feature | Stripe | Plaid | Adyen |
|---|---|---|---|
| Signature method | HMAC-SHA256 with timestamp (Stripe-Signature header, t=...,v1=...) | JWT with JWK verification (Plaid-Verification header) | HMAC-SHA256 (HmacSignature header) or basic auth |
| Retry policy | Up to 3 days, exponential backoff (roughly 1hr, 2hr, 4hr, 8hr, then daily) | Automatic retries with backoff for 24 hours | Retries every minute for 1 hour, then hourly, then configurable (up to days) |
| Event ordering | Not guaranteed. Use event timestamp or object state, not delivery order. | Not guaranteed. Use webhook_code and item_id to reconcile state. | Not guaranteed. Use eventDate and merchantReference for ordering. |
| Delivery format | JSON POST. Event object wraps the resource in data.object. | JSON POST. Webhook body includes webhook_type, webhook_code, and item_id. | JSON POST. Notification contains notificationItems array with event details. |
| Test mode | CLI (stripe listen --forward-to) or dashboard test events | Sandbox environment with /sandbox/item/fire_webhook | Customer Area test notifications or API-triggered test events |
| Acknowledgment | Return HTTP 200 within 20 seconds. Any 2xx is accepted. | Return HTTP 200. Non-2xx triggers retry. | Return [accepted] in response body. HTTP 200 alone is not sufficient. |
| Event types | ~200+ event types (payment_intent.succeeded, invoice.paid, etc.) | ~30 webhook codes grouped by product (TRANSACTIONS, ITEM, AUTH, etc.) | ~50 event codes (AUTHORISATION, CAPTURE, REFUND, CHARGEBACK, etc.) |
Disclaimer: This guide provides general webhook implementation guidance. Provider-specific behaviors, retry schedules, and signature methods are subject to change. Always consult the official documentation for Stripe, Plaid, and Adyen for current specifications.