Skip to content

Diagnostic Stripe

no endpoint subscribes to any payment failure event

Every payment that succeeds is handled. Every payment that fails is nothing at all: no event, no branch, no row, no email. The cart stays in processing until somebody writes in, and on the billing side a declined renewal starts a dunning sequence that the application knows nothing about while it is happening.

Read-only key Python and Node.js Tests included
Black android smartphone
Photo by Kelly Sikkema on Unsplash
The short answer

Union enabled_events across every endpoint. Flag the one-off surface if payment_intent.succeeded is subscribed and payment_intent.payment_failed is not. Flag the billing surface separately if the account has active subscriptions and invoice.payment_failed is not subscribed.

Then quantify it: GET /v1/events?types[]=invoice.payment_failed. A non-empty result is the number of failures that have already happened without anybody being told.

The problem in plain words

Failure is the same shape as silence here, which is why this survives. A PaymentIntent that fails does not disappear or error; it drops back to requires_payment_method and sits there. A subscription invoice that fails moves to past_due and starts a retry schedule. Both states are perfectly visible in the Dashboard and completely invisible to an application that was only ever told about successes.

The consequences separate along the same two surfaces. On the one-off side, orders stay in an intermediate state forever and nobody ever asks the customer for a different card. On the billing side the retries run, the customer receives whatever Stripe is configured to send, and your application keeps them fully provisioned throughout — and then, if you also lack the cancellation event, keeps them provisioned afterwards too.

Renewaldeclinescard expiredInvoice pastdueretries scheduledNo subscriptionevent neverdeliveredAccessunchangedstill fullyprovisionedFound bysupportweeks of freeservice
The failure state looks exactly like silence: the intent drops back and the invoice goes past due, and neither pushes anything.

Why it happens

You subscribe to what you tested. The integration is built against a card that succeeds, the event that proves it works is the success event, and that is the one that goes into enabled_events. The declining test card gets used once to check the error message in the UI, which is client-side and needs no webhook at all.

Failure is two separate surfaces, and neither implies the other. payment_intent.payment_failed covers a payment attempt that did not go through. invoice.payment_failed covers a billing attempt: a renewal declined, a soft decline that will be retried, or an invoice with no payment method available to charge. Subscribing to one leaves the other blind, and neither is implied by any success subscription.

Nothing pushes when nothing happens. There is no event for "this cart has been in processing for six days". The failure event is the only push signal you get, and if it is not subscribed the only way to find these is a periodic sweep of intent and invoice states that somebody has to write.

Authentication failures on renewals look like a third thing. A renewal that needs 3D Secure produces invoice.payment_action_required, not a decline. Without it, subscriptions freeze part-way through a payment that the customer could complete in one click if anything told them to.

The fix, as a flow

The script checks two surfaces separately, because a missing invoice event is noise on an account with no subscriptions and a running incident on one where invoices are already failing.

Union of enabled_eventsplus subscriptions and failuresBoth failures subscribedcoveredNo payment events at allthe endpoint is the gapSuccess without failuredeclines go nowhereBilling gap, none failed yeta gap to closeBilling gap, invoices failedan incident running now
The one off path and the billing path are separate subscriptions, and no success event implies either of them.

How to fix it

Union enabled_events across every endpoint, both modes

Coverage is an account-level property. Payment events and billing events often live on different endpoints, and reading either one alone gives the wrong answer.

Check the one-off surface

payment_intent.succeeded present and payment_intent.payment_failed absent is the signature: a success path that was wired up deliberately and a failure path that was never considered.

Check the billing surface, but only if it applies

GET /v1/subscriptions?limit=1&status=active. If the account has recurring billing at all, invoice.payment_failed is mandatory and its absence is a finding; on an account with no subscriptions it is noise.

Count the failures that already happened

GET /v1/events?types[]=invoice.payment_failed over the retained window. This is what turns a configuration gap into a number of customers, and it is usually the sentence that gets the fix prioritised.

Subscribe to both, then decide what the handler does

Add payment_intent.payment_failed and invoice.payment_failed, plus invoice.payment_action_required if you take renewals with 3D Secure. Then write the branches: mark the order failed and prompt for another card, and on the billing side start warning rather than revoking, because the retries may still succeed.

How to check it worked

Re-run the script. Both surfaces should report covered.

python3 stripe_payment_failure_events.py
# covered   both payment and invoice failure events are subscribed

The full code

Three GETs and no writes — the endpoints, one subscription to establish whether billing applies, and a type-filtered event read to size it. The classifier takes all three, because the same missing subscription is a tidy-up on an account with no recurring billing and an incident on one where invoices are already failing.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_payment_failure_events.py
"""Report whether anything subscribes to Stripe payment and invoice failure events.

Read only. Three GETs, no writes: give this a RESTRICTED key with read access to
Webhook Endpoints, Subscriptions and Events. The repair is printed, never
performed, because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_payment_failure_events")

API = "https://api.stripe.com/v1"

PI_OK = "payment_intent.succeeded"
PI_FAIL = "payment_intent.payment_failed"
INV_FAIL = "invoice.payment_failed"


def verdict(subscribed, has_active_subscriptions, failed_invoices):
    """Classify payment-failure coverage across both surfaces. Pure and testable.

    `subscribed` is the union of enabled_events across every endpoint. The
    billing surface only counts when the account actually has recurring billing,
    and failures already seen turn a gap into an incident. Returns (state, detail).
    """
    events = set(subscribed or [])
    if "*" in events:
        return ("wildcard",
                "a wildcard covers both failure events, and every other type "
                "along with them.")

    one_off_gap = PI_OK in events and PI_FAIL not in events
    billing_gap = bool(has_active_subscriptions) and INV_FAIL not in events

    if billing_gap and failed_invoices:
        return ("blind",
                "%d invoice payment(s) already failed and %s is not subscribed. "
                "Dunning is running right now and nothing is being told."
                % (failed_invoices, INV_FAIL))
    if one_off_gap and billing_gap:
        return ("exposed",
                "neither %s nor %s is subscribed. Both the one-off and the "
                "billing failure paths are silent." % (PI_FAIL, INV_FAIL))
    if one_off_gap:
        return ("one-sided",
                "%s is subscribed and %s is not: the success path is wired and "
                "declines go nowhere." % (PI_OK, PI_FAIL))
    if billing_gap:
        return ("billing-gap",
                "the account has active subscriptions and %s is not subscribed. "
                "Renewal declines and exhausted retries are invisible." % INV_FAIL)
    if PI_FAIL in events or INV_FAIL in events:
        return ("covered", "both applicable failure events are subscribed")
    return ("no-payment-events",
            "nothing subscribes to payment success or failure at all. The gap "
            "here is the endpoint configuration rather than one event type.")


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
    r.raise_for_status()
    return r.json()


def subscribed_union(session):
    """Every event type any endpoint on this account asks for."""
    union = set()
    for ep in get(session, "/webhook_endpoints", limit=100).get("data", []):
        union.update(ep.get("enabled_events") or [])
    return union


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-events", type=int, default=100,
                    help="how many failure events to count when sizing the gap")
    args = ap.parse_args()

    key = os.environ.get("STRIPE_API_KEY")
    if not key:
        log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + key})

    union = subscribed_union(s)
    active = get(s, "/subscriptions", limit=1, status="active").get("data", [])
    failures = get(s, "/events", limit=args.max_events,
                   **{"types[]": [INV_FAIL]}).get("data", [])

    state, detail = verdict(union, bool(active), len(failures))
    line = "%-17s %s" % (state, detail)
    if state in ("covered", "wildcard"):
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  repair: add enabled_events[]=%s and enabled_events[]=%s to an "
                "existing endpoint at %s/webhook_endpoints/{id}", PI_FAIL, INV_FAIL, API)
    log.warning("  add invoice.payment_action_required as well if renewals use 3D Secure")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-payment-failure-events.mjs
/**
 * Report whether anything subscribes to Stripe payment and invoice failure events.
 *
 * Read only. Three GETs, no writes: give this a RESTRICTED key with read access
 * to Webhook Endpoints, Subscriptions and Events. The repair is printed, never
 * performed.
 */
const API = 'https://api.stripe.com/v1';

const PI_OK = 'payment_intent.succeeded';
const PI_FAIL = 'payment_intent.payment_failed';
const INV_FAIL = 'invoice.payment_failed';

/**
 * Classify payment-failure coverage across both surfaces. Pure and testable.
 * The billing surface only counts when the account actually has recurring
 * billing, and failures already seen turn a gap into an incident.
 */
export function verdict(subscribed, hasActiveSubscriptions, failedInvoices) {
  const events = new Set(subscribed ?? []);
  if (events.has('*')) {
    return ['wildcard',
      'a wildcard covers both failure events, and every other type along with them.'];
  }

  const oneOffGap = events.has(PI_OK) && !events.has(PI_FAIL);
  const billingGap = Boolean(hasActiveSubscriptions) && !events.has(INV_FAIL);

  if (billingGap && failedInvoices) {
    return ['blind',
      `${failedInvoices} invoice payment(s) already failed and ${INV_FAIL} is ` +
      'not subscribed. Dunning is running right now and nothing is being told.'];
  }
  if (oneOffGap && billingGap) {
    return ['exposed',
      `neither ${PI_FAIL} nor ${INV_FAIL} is subscribed. Both the one-off and ` +
      'the billing failure paths are silent.'];
  }
  if (oneOffGap) {
    return ['one-sided',
      `${PI_OK} is subscribed and ${PI_FAIL} is not: the success path is wired ` +
      'and declines go nowhere.'];
  }
  if (billingGap) {
    return ['billing-gap',
      `the account has active subscriptions and ${INV_FAIL} is not subscribed. ` +
      'Renewal declines and exhausted retries are invisible.'];
  }
  if (events.has(PI_FAIL) || events.has(INV_FAIL)) {
    return ['covered', 'both applicable failure events are subscribed'];
  }
  return ['no-payment-events',
    'nothing subscribes to payment success or failure at all. The gap here is ' +
    'the endpoint configuration rather than one event type.'];
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) {
    if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, item);
    else url.searchParams.set(k, v);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401) {
    throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

export async function subscribedUnion(key) {
  const union = new Set();
  const { data = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  for (const ep of data) for (const t of ep.enabled_events ?? []) union.add(t);
  return union;
}

async function main() {
  const key = process.env.STRIPE_API_KEY;
  if (!key) {
    console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
    process.exitCode = 2;
    return;
  }

  const union = await subscribedUnion(key);
  const { data: active = [] } = await get(key, '/subscriptions',
    { limit: 1, status: 'active' });
  const { data: failures = [] } = await get(key, '/events',
    { limit: 100, 'types[]': [INV_FAIL] });

  const [state, detail] = verdict(union, active.length > 0, failures.length);
  const line = `${state.padEnd(17)} ${detail}`;
  if (state === 'covered' || state === 'wildcard') {
    console.log(line);
    return;
  }

  console.warn(line);
  console.warn(`  repair: add enabled_events[]=${PI_FAIL} and ` +
               `enabled_events[]=${INV_FAIL} to an existing endpoint at ` +
               `${API}/webhook_endpoints/{id}`);
  console.warn('  add invoice.payment_action_required as well if renewals use 3D Secure');
  process.exitCode = 1;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests exist to keep the two surfaces from collapsing into one. A missing invoice.payment_failed means nothing on an account that has never sold a subscription and means a live dunning incident on one where invoices are already failing, and the same subscription list produces both verdicts depending on inputs that have nothing to do with webhooks.

test_stripe_payment_failure_events.py
from stripe_payment_failure_events import verdict


def test_success_subscribed_without_the_failure_is_one_sided():
    state, detail = verdict(["payment_intent.succeeded"], False, 0)
    assert state == "one-sided"
    assert "payment_intent.payment_failed" in detail


def test_active_subscriptions_with_failures_already_seen_is_an_incident():
    # Same missing subscription as the gap below; the failures make it live.
    state, detail = verdict(["payment_intent.succeeded",
                             "payment_intent.payment_failed"], True, 9)
    assert state == "blind"
    assert "9 invoice" in detail


def test_no_subscriptions_means_the_invoice_event_is_not_required():
    state, _ = verdict(["payment_intent.succeeded",
                        "payment_intent.payment_failed"], False, 0)
    assert state == "covered"


def test_both_surfaces_missing_is_reported_as_one_finding():
    state, _ = verdict(["payment_intent.succeeded", "invoice.paid"], True, 0)
    assert state == "exposed"


def test_an_account_with_no_payment_events_at_all():
    state, _ = verdict(["customer.created"], False, 0)
    assert state == "no-payment-events"


def test_a_wildcard_covers_both_surfaces():
    state, _ = verdict(["*"], True, 40)
    assert state == "wildcard"
stripe-payment-failure-events.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-payment-failure-events.mjs';

test('success subscribed without the failure is one sided', () => {
  const [state, detail] = verdict(['payment_intent.succeeded'], false, 0);
  assert.equal(state, 'one-sided');
  assert.match(detail, /payment_intent\.payment_failed/);
});

test('active subscriptions with failures already seen is an incident', () => {
  const [state, detail] = verdict(
    ['payment_intent.succeeded', 'payment_intent.payment_failed'], true, 9);
  assert.equal(state, 'blind');
  assert.match(detail, /9 invoice/);
});

test('no subscriptions means the invoice event is not required', () => {
  const [state] = verdict(
    ['payment_intent.succeeded', 'payment_intent.payment_failed'], false, 0);
  assert.equal(state, 'covered');
});

test('both surfaces missing is reported as one finding', () => {
  const [state] = verdict(['payment_intent.succeeded', 'invoice.paid'], true, 0);
  assert.equal(state, 'exposed');
});

test('an account with no payment events at all', () => {
  assert.equal(verdict(['customer.created'], false, 0)[0], 'no-payment-events');
});

test('a wildcard covers both surfaces', () => {
  assert.equal(verdict(['*'], true, 40)[0], 'wildcard');
});

FAQ

Isn't a failed payment already visible in the Dashboard?

Yes, and nowhere in your application. The Dashboard shows the intent sitting in requires_payment_method and the invoice sitting past_due; your order table shows processing, your emails do not go out, and your access checks keep returning true. The event is what closes that gap.

What is the difference between payment_intent.payment_failed and invoice.payment_failed?

The surface. payment_intent.payment_failed fires when a payment attempt does not go through, which is what a one-off checkout produces. invoice.payment_failed fires when a billing attempt fails, which is what a subscription renewal produces, including the case where there is no payment method available to charge at all.

Do I need both if I only sell subscriptions?

invoice.payment_failed is the one that matters for renewals. Keep payment_intent.payment_failed as well if any first payment or one-off charge happens outside the billing flow, which on most accounts it does eventually.

Does Stripe retry a failed subscription payment on its own?

Yes. Smart Retries reattempt the invoice on a schedule, so a single failure is not a cancellation and revoking access on the first one is wrong. The failure events tell you where in that sequence you are; the end of it arrives as customer.subscription.deleted, which is a separate subscription of its own.

What about renewals that need 3D Secure?

That is invoice.payment_action_required rather than a decline. Without it, the subscription stalls waiting for an authentication the customer has not been asked for, which reads as a failure that no failure event explains.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.