GuideStripe Integration

How to Handle Stripe invoice.payment_failed Webhooks

Step-by-step technical guide to setting up Stripe webhooks for failed payments. Endpoint setup, payload parsing, idempotency, and testing.

9 min readFebruary 6, 2026By Rekko Team

In this guide: Complete walkthrough with formulas, examples, and practical tips you can apply today.

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:

  1. Accept POST requests with a JSON body
  2. Verify the Stripe signature
  3. Parse the event
  4. 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 address
  • customer_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 retries
  • status - 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") and failure_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.

Stripewebhooksinvoice.payment_failedtechnical

Put This Into Practice

Understanding churn metrics is the first step. Rekko helps you act on them by automatically recovering failed payments.

Related Guides