Every payment recovery workflow starts at the same place: knowing a payment failed. In Stripe, that means handling the invoice.payment_failed webhook event. If you get this wrong, you miss failures, process duplicates, or silently drop events. None of those are acceptable when revenue is on the line.
This guide covers the full technical setup, from creating your endpoint to testing it with Stripe CLI.
How Stripe webhook events work
When something happens in Stripe (a payment succeeds, a subscription updates, a charge is disputed), Stripe sends an HTTP POST request to a URL you specify. That POST contains a JSON payload describing the event.
For payment failures, Stripe fires invoice.payment_failed every time a charge attempt on an invoice fails. That includes the first attempt and each automatic retry. A single invoice can generate multiple invoice.payment_failed events over the course of your retry schedule.
Your webhook endpoint needs to receive these events, verify they came from Stripe, extract the relevant data, and trigger your recovery workflow.
Step 1: Create your webhook endpoint
Your endpoint needs to do four things:
- Accept POST requests with a JSON body
- Verify the Stripe signature
- Parse the event
- Return a 200 status quickly
Here's the basic structure in pseudocode:
ENDPOINT POST /webhooks/stripe
raw_body = request.raw_body
signature = request.headers["Stripe-Signature"]
TRY
event = stripe.webhooks.constructEvent(
raw_body,
signature,
WEBHOOK_SECRET
)
CATCH error
RETURN status 400, "Invalid signature"
IF event.type == "invoice.payment_failed"
invoice = event.data.object
handle_failed_payment(invoice)
RETURN status 200, "OK"
A critical detail: you must use the raw request body for signature verification, not a parsed JSON object. Stripe signs the raw bytes. If your framework parses the body before you verify, the signature check will fail.
Step 2: Register the endpoint in Stripe
Go to the Stripe Dashboard and navigate to Developers > Webhooks. Click "Add endpoint."
Endpoint URL: Your publicly accessible URL (e.g., https://yourapp.com/webhooks/stripe).
Events to send: Select invoice.payment_failed. You might also want invoice.payment_succeeded to know when a recovery works, and customer.subscription.deleted for tracking churn. But for payment recovery, invoice.payment_failed is the essential one.
Stripe will give you a webhook signing secret (starts with whsec_). Store this securely in your environment variables. You need it for signature verification.
Step 3: Verify the signature
Every webhook event from Stripe includes a Stripe-Signature header. This header contains a timestamp and one or more signatures. Stripe's official libraries have a method to verify this signature against your webhook secret.
Never skip verification. Without it, anyone who discovers your endpoint URL can send fake events. That could trigger false dunning emails, corrupt your data, or worse.
The verification also checks the timestamp. By default, Stripe's libraries reject events older than 5 minutes (300 seconds). You can adjust this tolerance, but keep it tight. A replayed event from an hour ago is suspicious.
// Pseudocode for signature verification
event = stripe.webhooks.constructEvent(
raw_body, // The raw request body as a string
signature_header, // The Stripe-Signature header value
webhook_secret, // Your whsec_ signing secret
tolerance: 300 // Reject events older than 5 minutes
)
If verification fails, return a 400 status and stop processing. Log the failure for debugging.
Step 4: Parse the event payload
The invoice.payment_failed event contains the full invoice object under event.data.object. Here are the fields you care about:
Customer identification:
customer- The Stripe customer ID (e.g.,cus_abc123)customer_email- The customer's email addresscustomer_name- The customer's name (if set)
Invoice details:
id- The invoice ID (e.g.,in_abc123)amount_due- Amount in cents (e.g., 4900 = $49.00)currency- Three-letter currency code (e.g., "usd")subscription- The subscription ID this invoice belongs to
Failure information:
attempt_count- How many times Stripe has tried to charge this invoice (starts at 1)next_payment_attempt- Unix timestamp of the next retry, or null if no more retriesstatus- Will be "open" if retries remain, "uncollectible" if all retries exhausted
Charge details (nested):
charge- The charge ID, which you can expand to get decline details- The charge object contains
failure_code(e.g., "card_declined", "expired_card") andfailure_message
The attempt_count field is particularly useful. On the first failure (attempt_count: 1), you might send a gentle notification. On the third failure, you escalate.
next_payment_attempt tells you whether Stripe will retry. If it's null, Stripe has given up. That's your cue to escalate or take action.
Step 5: Handle idempotency
Stripe guarantees at-least-once delivery, not exactly-once. That means you might receive the same event multiple times. Your endpoint must handle duplicates gracefully.
Every Stripe event has a unique id field (e.g., evt_abc123). Store the IDs of events you've processed and check against them before processing.
// Pseudocode for idempotent processing
event_id = event.id
IF already_processed(event_id)
RETURN status 200, "Already processed"
process_event(event)
mark_as_processed(event_id)
RETURN status 200, "OK"
Where you store processed event IDs depends on your stack. A database table works. Redis with a TTL of 24-48 hours works for lighter setups. The point is: always check before processing.
A second layer of idempotency: use the invoice ID and attempt count together as a deduplication key. Even if you miss an event ID check, you won't send duplicate dunning emails for the same invoice attempt.
Step 6: Respond quickly
Stripe expects your endpoint to respond within 20 seconds. If it doesn't, Stripe considers the delivery failed and will retry.
Your endpoint should not do heavy processing inline. Accept the event, validate it, enqueue it for background processing, and return 200 immediately.
// Pseudocode for async processing
ENDPOINT POST /webhooks/stripe
event = verify_and_parse(request)
IF event.type == "invoice.payment_failed"
queue.enqueue("process_failed_payment", event.data.object)
RETURN status 200, "OK"
Background processing (via a job queue like BullMQ, Celery, Sidekiq, or similar) lets you retry your own processing logic if something fails, without losing the Stripe event.
Step 7: Extract and store the data you need
When processing the failed payment in the background, extract and store the relevant data:
// Pseudocode for processing
FUNCTION process_failed_payment(invoice)
customer_id = invoice.customer
customer_email = invoice.customer_email
customer_name = invoice.customer_name
amount = invoice.amount_due
currency = invoice.currency
subscription = invoice.subscription
attempt = invoice.attempt_count
next_retry = invoice.next_payment_attempt
invoice_id = invoice.id
// Get decline reason from the charge
IF invoice.charge
charge = stripe.charges.retrieve(invoice.charge)
decline_code = charge.failure_code
decline_message = charge.failure_message
// Classify: soft decline or hard decline
hard_decline_codes = ["expired_card", "card_not_supported",
"stolen_card", "fraudulent"]
is_hard_decline = decline_code IN hard_decline_codes
// Store the failed payment record
save_failed_payment(
invoice_id, customer_id, customer_email, customer_name,
amount, currency, subscription, attempt,
next_retry, decline_code, is_hard_decline
)
// Trigger dunning sequence
IF attempt == 1
start_dunning_sequence(customer_id, invoice_id, is_hard_decline)
ELSE
update_dunning_sequence(customer_id, invoice_id, attempt)
The distinction between soft and hard declines matters. A soft decline (insufficient funds, temporary error) might resolve on retry. A hard decline (expired card, invalid number) won't. Your messaging should reflect this.
Common mistakes
Not using the raw body for verification. If your web framework auto-parses JSON, the parsed-then-re-serialized body won't match Stripe's signature. Make sure you access the raw bytes.
Processing synchronously. If your email service is slow or your database hiccups, the webhook handler times out. Stripe retries. You end up processing the same event multiple times, potentially sending duplicate emails.
Ignoring attempt_count. Treating every invoice.payment_failed event identically means sending the same "first failure" email on every retry. Use the attempt count to tailor your response.
Not handling multiple events for the same invoice. A single invoice can fail 4+ times across Stripe's retry schedule. Your system needs to track state per invoice, not just react to individual events.
Hardcoding the webhook secret. Store it in an environment variable. Never commit it to your repository.
Returning non-200 status codes for events you don't handle. If you receive an event type you don't care about, still return 200. Otherwise Stripe will keep retrying it. Only return 4xx/5xx if there's a genuine error.
Testing with Stripe CLI
The Stripe CLI lets you test webhooks locally without deploying. This is the fastest way to iterate.
Install and authenticate:
stripe login
Forward events to your local server:
stripe listen --forward-to localhost:4005/api/webhooks/stripe
This gives you a temporary webhook secret (starts with whsec_). Use it in your local environment.
Trigger a test event:
stripe trigger invoice.payment_failed
This sends a mock invoice.payment_failed event to your local endpoint. Watch your server logs to confirm it's received and processed correctly.
Trigger with specific data:
For more realistic testing, create a test customer with a test card that always declines:
stripe customers create --email [email protected]
stripe subscriptions create \
--customer [CUSTOMER_ID] \
--items[0][price] [PRICE_ID] \
--payment-behavior default_incomplete
Use Stripe's test card numbers that simulate declines (e.g., 4000000000000341 for a card that fails after attaching). This produces real invoice.payment_failed events with realistic payloads.
Monitoring in production
Once your webhook endpoint is live, monitor it:
Stripe Dashboard: Go to Developers > Webhooks > [Your endpoint]. You'll see successful deliveries, failed deliveries, and pending retries. Stripe shows the response status code and body for each attempt.
Error rate: Track the percentage of webhooks that fail processing on your end. A spike means something broke.
Processing lag: Measure the time between receiving an event and completing processing. If your queue backs up, customers wait longer for their first dunning email.
Missing events: Compare the count of invoice.payment_failed events in Stripe's event log with the count of failed payment records in your database. They should match (minus duplicates you intentionally skipped).
Putting it together
A well-built webhook handler is the foundation of payment recovery. The technical work here isn't glamorous, but it determines whether you catch every failed payment or let some slip through.
The chain is simple: Stripe fires the event, your endpoint receives and verifies it, you enqueue it for processing, and your dunning sequence kicks off. Each step needs to be reliable, idempotent, and fast.
If you'd rather skip the engineering work, tools like Rekko handle the webhook integration for you. Connect your Stripe account and the webhook listener, event processing, and dunning automation are set up automatically. But if you're building it yourself, the approach above will get you a solid foundation.