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's pspReference) 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_body using your webhook signing secret. The Stripe-Signature header 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-Verification header 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.hmacSignature field.

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_webhook to 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

FeatureStripePlaidAdyen
Signature methodHMAC-SHA256 with timestamp (Stripe-Signature header, t=...,v1=...)JWT with JWK verification (Plaid-Verification header)HMAC-SHA256 (HmacSignature header) or basic auth
Retry policyUp to 3 days, exponential backoff (roughly 1hr, 2hr, 4hr, 8hr, then daily)Automatic retries with backoff for 24 hoursRetries every minute for 1 hour, then hourly, then configurable (up to days)
Event orderingNot 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 formatJSON 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 modeCLI (stripe listen --forward-to) or dashboard test eventsSandbox environment with /sandbox/item/fire_webhookCustomer Area test notifications or API-triggered test events
AcknowledgmentReturn 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.