---
title: "Stripe Payment Failure Alerts: Disputes, Declines, and Dead Webhooks"
description: "Stripe emails you about disputes and failed payments. Here's how to turn Stripe webhook events into a push, a time-sensitive alert, or a phone call."
date: 2026-09-11
author: Nooc
authorAvatarLink: /images/avatars/nooc.webp
authorLink: https://nooc.me
tags:
  - Stripe
  - payments
  - webhooks
  - disputes
  - SaaS
  - alerting
---

# Stripe Payment Failure Alerts: Disputes, Declines, and Dead Webhooks

Stripe already knows when a dispute opens, a subscription payment fails, or a payout bounces. It tells you by email. To get something louder, register a second webhook endpoint in Stripe that points at an Echobell channel URL, subscribe it to a handful of event types, and pick the notification type that matches the deadline attached to each one. Disputes and early fraud warnings have clocks running; a failed renewal usually does not.

This guide covers which Stripe events are worth an interruption, how to point Stripe at Echobell in about five minutes, the template fields that actually exist on each object, and the trade-off you accept by skipping signature verification.

## The events that actually deserve an interruption

Most payment alerting goes wrong in the same way: someone subscribes a channel to `payment_intent.succeeded` because it feels good, the phone buzzes forty times a day, and six weeks later a dispute notification scrolls past unread. Start from the deadline instead. If missing the event for eight hours costs nothing, it does not need to reach you in eight seconds.

| Event | Why it matters | Suggested type |
| --- | --- | --- |
| `charge.dispute.created` | You have a limited window to respond — [usually 7 to 21 days, depending on the card network](https://docs.stripe.com/disputes/responding). Miss it and you lose automatically. | Calling |
| `radar.early_fraud_warning.created` | The card issuer has told Stripe a charge may be fraudulent. Refunding before it becomes a formal dispute is the action that's still available to you, and the window is short. | Calling |
| `payout.failed` | Money Stripe collected cannot reach your bank. Everything downstream — payroll, runway math — is now wrong. | Calling |
| `invoice.payment_failed` | Involuntary churn. It resolves itself often enough that a call is overkill, but the largest accounts are worth a look the same day. | Time-sensitive |
| `customer.subscription.deleted` | Voluntary churn. Worth knowing today, not worth waking up for. | Normal |
| `payment_intent.succeeded` | Nothing is broken. This is the event that trains you to ignore the other five. | Nothing |

The tiers map to Echobell's three [notification types](/en/docs/notification): **Normal** is an ordinary push, **Time Sensitive** breaks through most Focus Modes, and **Calling** presents as an incoming call, which rings through Do Not Disturb. Each subscriber picks their own level per channel, so a co-founder can take disputes as a call while a support teammate takes them as a push.

## What you need

- A Stripe account with access to the [Webhooks tab in Workbench](https://dashboard.stripe.com/webhooks)
- Echobell installed ([App Store](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-en&mt=8) / [Google Play](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid))
- Five minutes. No server, no deploy, no code — unless you want signature verification, which is the last section.

## Step 1 — One channel per event type

It's tempting to make a single "Stripe" channel and send everything to it. Don't. The body template and the dashboard link are different for a dispute, an invoice, and a payout, because each carries a different object — and the whole point of the setup is that the notification tells you what happened without opening anything.

Create a channel named after the event: `Stripe Disputes`. Set the title and body templates to read cleanly on a lock screen:

```
Title: 🔴 Dispute opened — {{data.object.reason}}
Body: Amount: {{data.object.amount}} {{data.object.currency}}
Charge: {{data.object.charge}}
Status: {{data.object.status}}
```

Set the link template in advanced settings so the notification record opens the right page:

```
https://dashboard.stripe.com/disputes/{{data.object.id}}
```

Then subscribe yourself with type **Calling**, and copy the webhook URL from the channel detail view. It looks like `https://hook.echobell.one/t/<channel-token>`.

<Callout type="info">Turn on **POST Only** in the channel's advanced settings. Stripe always sends POST, and the switch means that pasting the URL into a chat window or a browser tab can no longer fire a fake dispute alert.</Callout>

## Step 2 — Point Stripe at the channel

In the Stripe Dashboard, open the [Webhooks tab](https://dashboard.stripe.com/webhooks) and create an event destination:

<Steps>

<Step>

Click **Create an event destination**, select **Your account**, and leave the API version at your account default.

</Step>

<Step>

Select exactly one event type — `charge.dispute.created` for this channel. Stripe's own advice is to [subscribe only to the events your integration needs](https://docs.stripe.com/webhooks); here it also keeps the template honest, because every payload that arrives has the same shape.

</Step>

<Step>

Choose **Webhook endpoint** as the destination type and paste the Echobell channel URL.

</Step>

<Step>

Save, then use the **Send test event** action — or `stripe trigger charge.dispute.created` from the CLI — and confirm your phone rings.

</Step>

</Steps>

Repeat for each channel you created. Stripe allows up to 16 webhook endpoints per account, which is more than enough for one per alert tier.

## Step 3 — What actually arrives

Stripe posts the [Event object](https://docs.stripe.com/api/events/object) as JSON. Echobell reads the body as-is, so every field is addressable in templates and conditions with dot notation:

```json
{
  "id": "evt_1P...",
  "type": "charge.dispute.created",
  "livemode": true,
  "created": 1757548800,
  "data": {
    "object": {
      "id": "dp_1P...",
      "amount": 4900,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "charge": "ch_3P...",
      "evidence_details": { "due_by": 1759449600 }
    }
  }
}
```

Three things about this payload surprise people:

**Amounts are integers in the smallest currency unit.** `amount` of `4900` is $49.00. Echobell templates interpolate and compare values but do not do arithmetic, so `{{data.object.amount}}` renders `4900`. Either label it honestly (`Amount: 4900 (cents)`), or use the forwarder in the last section to divide by 100 before sending.

**Timestamps are Unix seconds.** `{{data.object.evidence_details.due_by}}` renders as `1759449600`, not a date. If the deadline matters more than the exact hour, drop it from the template — the dispute page shows it — and let the link template do the work.

**Field names differ per object.** A dispute has `amount`; an invoice has `amount_due`, `customer_email`, `attempt_count` and `hosted_invoice_url`; a payout has `failure_message` and `arrival_date`; an early fraud warning has `fraud_type`, `actionable`, and `charge` as a plain string ID. A missing variable renders as an empty string rather than an error, so a template copied from the wrong channel fails quietly. This is the practical reason for one channel per event type.

## Step 4 — Filter with conditions, not with willpower

Channel [conditions](/en/docs/conditions) use the same expression syntax as templates, without the braces, and they run before anything is delivered.

The one to set on every Stripe channel:

```
livemode == true
```

Test-mode traffic — your own `stripe trigger` runs, a teammate poking at a sandbox — no longer reaches your phone. Add it after you've confirmed the wiring works, not before.

For the failed-invoice channel, a threshold keeps the small accounts out of your evening:

```
livemode == true && data.object.amount_due > 20000
```

That reads as "over $200," in cents. And if you'd rather see the retries that have genuinely stalled instead of every first attempt:

```
livemode == true && data.object.attempt_count > 1
```

If you did point one endpoint at one channel for several event types, conditions can split them back apart:

```
type == "charge.dispute.created" || type == "payout.failed"
```

## Step 5 — Keep the alert path outside the thing that breaks

Here's the part worth more than the templates.

Your production webhook endpoint is where fulfillment happens: it grants access, writes to the database, emails the receipt. It is also, therefore, the endpoint that goes down when your app goes down. When it does, Stripe [retries for up to three days with exponential backoff](https://docs.stripe.com/webhooks) and sends you an email — and an email about undelivered webhooks looks identical to every other Stripe email, which is why it gets found on Monday.

Silent webhook death has boring causes. Stripe treats a `3xx` redirect as a failure, so an endpoint that starts redirecting `http` to `https` or appending a trailing slash stops receiving events. It requires TLS 1.2 or higher, so an expired or misconfigured certificate is enough. A `403` from a WAF rule someone added last week does it too.

A second endpoint pointed straight at Echobell shares none of that. It is a different URL on a different host with a different certificate, and it keeps ringing while your app is down. The rule generalizes: *the path that tells you something is broken should not run through the thing that is broken.*

You still want the failure of your own endpoint to be visible. Check the **Event deliveries** tab in Workbench when something feels off — it shows `Delivered`, `Pending`, and `Failed` per event, with the HTTP status of each attempt. Stripe lets you resend an event for up to 15 days from the Dashboard, or 30 days with `stripe events resend` from the CLI, so a gap caught within a fortnight is recoverable.

<Callout type="warn">The Echobell channel URL is a bearer credential: anyone holding it can trigger the channel. Pointing Stripe at it directly means nothing verifies the `Stripe-Signature` header, so a leaked URL is a fake-alert machine, not a data breach. Keep it out of repositories and screenshots, use **Reset Token** if it escapes, and read the next section if the trade-off bothers you.</Callout>

## Optional — Verify the signature first

If you want Stripe's signature actually checked, and amounts formatted like money, put a small forwarder in front. This Cloudflare Worker verifies the event, returns `200` immediately as Stripe asks, and sends Echobell a flat payload:

```js
import Stripe from "stripe";

export default {
  async fetch(request, env, ctx) {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const body = await request.text();

    let event;
    try {
      event = await stripe.webhooks.constructEventAsync(
        body,
        request.headers.get("stripe-signature"),
        env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return new Response("invalid signature", { status: 400 });
    }

    const invoice = event.data.object;
    ctx.waitUntil(
      fetch(env.ECHOBELL_HOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customer: invoice.customer_email || invoice.customer,
          amount: (invoice.amount_due / 100).toFixed(2),
          currency: invoice.currency.toUpperCase(),
          attempt: invoice.attempt_count,
          externalLink: invoice.hosted_invoice_url,
        }),
      }),
    );

    return new Response("ok", { status: 200 });
  },
};
```

The template on the other side gets much nicer, because the shaping happened in code:

```
Title: 💳 Payment failed — {{currency}} {{amount}}
Body: Customer: {{customer}}
Attempt #{{attempt}}
```

`externalLink` is a special variable: with no link template set, Echobell uses it for the notification record's link, so the hosted invoice page is one tap away.

Note the shape of the trade: this is now a piece of infrastructure that can itself fail, which is exactly what Step 5 warns about. A reasonable compromise is to verify signatures on the high-volume channel where fake alerts would be annoying, and keep the dispute channel wired directly, where the cost of a spurious ring is one confused glance and the cost of a missed one is the disputed amount.

## What this setup does not give you

- **No on-call rotation or escalation.** Everyone subscribed to a calling channel is rung at once. That's a feature at four people and a problem at forty; at forty you want an incident platform.
- **No deduplication.** Stripe [doesn't guarantee event ordering](https://docs.stripe.com/webhooks) and may deliver the same event more than once. Two rings for one dispute is possible.
- **No acknowledgement.** Nothing records that a human saw it, and nothing escalates to a second person if nobody does.
- **No "payments stopped" alert.** Stripe emits events when things happen, never when they stop. If your checkout breaks, no event fires at all. That one needs a scheduled job on your side that pings a channel when the last hour's charge count is zero — a [cron-based dead man's switch](/en/blog/cron-job-failure-alerts).

## Troubleshooting

**The test event shows `200` in Stripe but no notification arrived.** Echobell answers `200` with a JSON body even when it doesn't deliver — check the response body in the Event deliveries tab. `success: false` with a valid-length token means the channel token is wrong. If `success` is `true`, the likely cause is a condition: `livemode == true` blocks every test event by design.

**Stripe reports `405 Method Not Allowed`.** The channel has POST Only on and something sent a GET. Stripe itself always posts, so this is a link preview or a browser tab, not Stripe.

**The notification arrives with blank fields.** The template is addressing the wrong object — `{{data.object.amount}}` on an invoice channel, where the field is `amount_due`. Send one real event, open the event in the Dashboard, and read the JSON.

**Deliveries fail after working for weeks.** Check the certificate and any redirect in front of the URL. For a channel URL used directly this is rare; for a forwarder you deployed, it's the usual suspect.

## Frequently asked questions

### Can Stripe call my phone when a dispute opens?

Not on its own. Stripe notifies you by email, in the Dashboard, through the `charge.dispute.created` event, and by push if you use the Stripe Dashboard app. To get an actual ring, route that event to a channel whose subscription type is **Calling**.

### Do I need to write any code to connect Stripe to Echobell?

No. Stripe posts JSON to any public HTTPS URL, and an Echobell channel URL is one. Code is only needed if you want the `Stripe-Signature` header verified or the amounts reformatted.

### Is it safe to give Stripe a third-party webhook URL?

It's a deliberate trade. The payload Stripe sends contains customer and payment metadata, and Echobell doesn't permanently store raw webhook payloads — the rendered notification lives on your device. What you give up is signature verification: anyone who learns the URL can send you a convincing fake. Treat it like an API key, and use the forwarder pattern for anything you'd rather verify.

### Why does my alert show 4900 instead of $49.00?

Stripe sends amounts as integers in the smallest currency unit, and Echobell templates don't perform arithmetic. Label the unit in the template, or divide by 100 in a forwarder before sending.

### How do I stop test-mode events from waking me up?

Add the condition `livemode == true` to the channel. Stripe marks every sandbox and `stripe trigger` event as `livemode: false`.

### Can my co-founder get the same alerts without paying for a seat?

Yes. Share the channel link; each subscriber chooses their own notification type. One person can take disputes as a call while another takes them as a normal push, and there is no per-seat pricing on subscribers.

### Should I alert on successful payments?

Only briefly, and only while the business is small enough that each one is still an event. The moment a successful-payment notification becomes routine, it starts eroding your response to the ones that matter — the core mechanic behind [alert fatigue](/en/blog/fix-alert-fatigue-developer-guide).

## Wrap-up

The whole setup is one Stripe event destination per Echobell channel, a `livemode == true` condition, and the discipline to reserve the calling tier for events with a clock attached. Disputes and early fraud warnings have one. A failed renewal on a $9 plan does not, and pretending otherwise is how you end up sleeping through the one that did.

[Download Echobell for iPhone](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-en&mt=8) or [get it on Google Play](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid), create the dispute channel first, and fire one `stripe trigger charge.dispute.created` before you trust the path with anything real.

---

## Related

- [Webhook integration docs](/en/docs/webhook)
- [Channel conditions reference](/en/docs/conditions)
- [A developer's guide to fixing alert fatigue](/en/blog/fix-alert-fatigue-developer-guide)
- [Cron job failure alerts](/en/blog/cron-job-failure-alerts)
- [Zapier webhook notifications to your phone](/en/blog/zapier-webhook-notifications-to-phone)
