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

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.

Updated

Table of Contents

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.

EventWhy it mattersSuggested type
charge.dispute.createdYou have a limited window to respond — usually 7 to 21 days, depending on the card network. Miss it and you lose automatically.Calling
radar.early_fraud_warning.createdThe 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.failedMoney Stripe collected cannot reach your bank. Everything downstream — payroll, runway math — is now wrong.Calling
invoice.payment_failedInvoluntary 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.deletedVoluntary churn. Worth knowing today, not worth waking up for.Normal
payment_intent.succeededNothing is broken. This is the event that trains you to ignore the other five.Nothing

The tiers map to Echobell's three notification types: 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

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>.

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.

Step 2 — Point Stripe at the channel

In the Stripe Dashboard, open the Webhooks tab and create an event destination:

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

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; here it also keeps the template honest, because every payload that arrives has the same shape.

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

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

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 as JSON. Echobell reads the body as-is, so every field is addressable in templates and conditions with dot notation:

{
  "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 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 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.

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.

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:

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 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.

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.

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 or get it on Google Play, create the dispute channel first, and fire one stripe trigger charge.dispute.created before you trust the path with anything real.