Skip to content

Diagnostic Stripe

live mode has no webhook endpoint, so nothing is ever pushed

The first real payment arrives and the application does nothing with it. No order row, no fulfilment email, no account provisioned. The charge is right there in the Dashboard, marked succeeded. It all worked perfectly in development, every single time, because stripe listen was doing the delivering and nobody noticed that it was the only thing that ever had.

Read-only key Python and Node.js Tests included
A product photo shoot
Photo by Randy Fath on Unsplash
The short answer

Call GET /v1/webhook_endpoints?limit=100 with a live restricted key and look at the length of data. Zero means Stripe has no registered destination for this mode and never pushed anything anywhere.

Then prove it matters: GET /v1/events?limit=100&types[]=payment_intent.succeeded and friends. Zero endpoints plus real payment traffic is the confirmed diagnosis. Zero endpoints on an account with no traffic yet is the same bug caught before it costs anything.

The problem in plain words

Every other webhook failure leaves a trace somewhere. A disabled endpoint has a status you can read. A failing one has attempts and response codes. This one has nothing at all, because there is no object to have a state: the events were generated, they were listed in /v1/events like always, and no destination existed for Stripe to push them to. There is nothing in your logs because nothing was ever sent to you, and nothing in Stripe's because there was no delivery to record.

What makes it survive review is that the integration genuinely works. The signature verification is correct, the handler is correct, the tests pass. All of it was exercised against stripe listen, which creates its own ephemeral destination and prints its own whsec_, and none of that configuration lives in your account. The step that was skipped is not code at all, so no code review would have caught it.

stripe listenephemeraldestinationHandlerverifiedagainst the CLIsecretShipped to liveno endpointcreatedPaymentsucceedsnowhere to pushNothing runsno order, no email
The CLI listener was the only destination that ever existed, and it lives on a laptop rather than in the account.

Why it happens

The CLI listener is a destination, and it is invisible to the API. stripe listen forwards events to your machine for as long as it is running. It does not create a webhook endpoint object, so GET /v1/webhook_endpoints returned an empty list throughout development and nobody had any reason to look.

The two modes are entirely separate configurations. Test-mode endpoints and live-mode endpoints are different objects with different secrets, and a key can only see its own mode. A team that did create a test endpoint, and checked it, has confirmed nothing whatsoever about live.

Nothing degrades: it simply never starts. There is no first successful delivery to compare against, no date the graph changed. The absence looks identical on day one and day ninety, which is why this is usually found by a customer asking where their thing is rather than by an alert.

The signing secret in your environment may be from the CLI. A whsec_ copied out of stripe listen output during development is mode-scoped and temporary. Even after somebody creates the endpoint, the handler will reject every live event with a 400 until the secret from the endpoint object replaces it, which turns this failure into the disabled-endpoint one a few days later.

The fix, as a flow

The script counts the endpoints in this key's mode and the payment events in the same window, because an empty list is a non-event on an account with no traffic and an outage on one taking money.

GET /v1/webhook_endpointsplus a count of payment eventsEnabled and livecoveredEnabled, test modesays nothing about liveNone, no traffic yetcreate before the first paymentNone, payments seenan outage already running
The same empty list means different things on a quiet test account and on a live one with a month of payments behind it.

How to fix it

List the endpoints with a live key, and read the count

GET /v1/webhook_endpoints?limit=100. An empty data array is the whole finding. Do this with a key whose prefix is rk_live_ or sk_live_: a test key will happily report the test-mode endpoint somebody made months ago and tell you nothing about production.

Confirm the mode from the objects, not from memory

Every Stripe object carries livemode. Read it off an event or an endpoint rather than trusting which key you think is in the environment. This is a two-line check that removes the most common way this diagnosis goes wrong.

Measure what has already been lost

GET /v1/events filtered to payment_intent.succeeded, checkout.session.completed and invoice.paid counts the business events that had nowhere to go. Events are retained for 30 days, so this count is both the size of the backlog and the size of the window you have to work through it.

Create the endpoint and take the secret from the response

POST /v1/webhook_endpoints with your production URL and an explicit enabled_events[] list. The secret is returned on creation; that value, not the one from the CLI, is what your handler must verify against.

Backfill from the source objects, not only from the events

Anything older than 30 days is gone from /v1/events for good. Charges, invoices and subscriptions are not retention-limited, so reconcile the gap from GET /v1/charges?created[gte]= and GET /v1/invoices?created[gte]= instead.

How to check it worked

Re-run the script with the live key. It should report at least one enabled endpoint in live mode.

python3 stripe_live_webhook_endpoints.py
# covered   1 enabled endpoint(s) in this mode

The full code

Two GETs and no writes — a restricted key with read access to Webhook Endpoints and Events is enough, and is what you should give it. The classifier takes the endpoint list, the count of payment events seen, and whether this is live mode, because the same empty list is a non-event on a fresh test account and an outage on a live one.

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_live_webhook_endpoints.py
"""Report whether this mode has a webhook endpoint at all, and whether it needs one.

Read only. Two GETs, no writes: give this a RESTRICTED key with read access to
Webhook Endpoints 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_live_webhook_endpoints")

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

# Events that prove this mode is carrying real business traffic. If any of these
# fired and no endpoint exists, work that should follow a payment never happened.
TRAFFIC_TYPES = ("payment_intent.succeeded", "checkout.session.completed",
                 "invoice.paid")


def verdict(endpoints, payment_events, livemode):
    """Classify webhook coverage for one mode. Pure, so the rules can be tested.

    `endpoints` is the raw data array from /v1/webhook_endpoints, `payment_events`
    the count of business events seen in the retained window, and `livemode`
    whether this key reads live data. Returns (state, detail).
    """
    eps = list(endpoints or [])
    enabled = [e for e in eps if e.get("status") == "enabled"]
    if not eps:
        if payment_events:
            return ("blind",
                    "%d payment event(s) in the retained window and no webhook "
                    "endpoint to receive them. Stripe had nowhere to push, so "
                    "nothing that should follow a payment ever ran."
                    % payment_events)
        return ("empty",
                "no webhook endpoint, and no payment events in the retained "
                "window either. Nothing has been lost yet: create the endpoint "
                "before the first real payment rather than after it.")
    if not enabled:
        return ("all-disabled",
                "%d endpoint(s) exist and every one of them is disabled, which "
                "delivers exactly as much as having none." % len(eps))
    if not livemode:
        return ("test-mode",
                "%d enabled endpoint(s), all test mode. A healthy test mode is "
                "what lets this ship: re-run with a live restricted key before "
                "concluding anything about production." % len(enabled))
    return ("covered", "%d enabled endpoint(s) in this mode" % len(enabled))


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")
    if r.status_code == 403:
        raise SystemExit("403 from Stripe: the restricted key lacks read access to "
                         + path)
    r.raise_for_status()
    return r.json()


def is_livemode(key):
    """Mode from the key prefix. Confirmed against object livemode where possible."""
    return not key.startswith(("sk_test_", "rk_test_", "pk_test_"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-events", type=int, default=100,
                    help="how many recent payment events to count as evidence")
    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})

    endpoints = get(s, "/webhook_endpoints", limit=100).get("data", [])
    events = get(s, "/events", limit=min(args.max_events, 100),
                 **{"types[]": list(TRAFFIC_TYPES)}).get("data", [])

    # Prefer the objects over the key prefix: a restricted key can be named
    # anything, but livemode on a returned object is the account's own answer.
    livemode = is_livemode(key)
    for obj in list(endpoints) + list(events):
        if "livemode" in obj:
            livemode = bool(obj["livemode"])
            break

    state, detail = verdict(endpoints, len(events), livemode)
    line = "%-12s %s" % (state, detail)
    if state == "covered":
        log.info(line)
        for ep in endpoints:
            log.info("  %s  %d subscribed type(s)",
                     ep.get("url", "?"), len(ep.get("enabled_events") or []))
        return 0

    log.warning(line)
    if state in ("blind", "empty"):
        log.warning("  repair: POST %s/webhook_endpoints", API)
        log.warning("    -d url=https://<your-domain>/stripe/webhook")
        log.warning("    -d enabled_events[]=payment_intent.succeeded")
        log.warning("    -d enabled_events[]=payment_intent.payment_failed")
        log.warning("  then copy the secret from the response into the server "
                    "environment: the whsec_ printed by the CLI is not it")
    if state == "blind":
        log.warning("  backfill: GET %s/charges?created[gte]=<unix> and "
                    "%s/invoices?created[gte]=<unix>, which are not retention "
                    "limited the way /v1/events is", API, API)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-live-webhook-endpoints.mjs
/**
 * Report whether this mode has a webhook endpoint at all, and whether it needs one.
 *
 * Read only. Two GETs, no writes: give this a RESTRICTED key with read access to
 * Webhook Endpoints and Events. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

const TRAFFIC_TYPES = ['payment_intent.succeeded', 'checkout.session.completed',
  'invoice.paid'];

/**
 * Classify webhook coverage for one mode. Pure, so the rules can be tested.
 */
export function verdict(endpoints, paymentEvents, livemode) {
  const eps = endpoints ?? [];
  const enabled = eps.filter((e) => e.status === 'enabled');
  if (eps.length === 0) {
    if (paymentEvents) {
      return ['blind',
        `${paymentEvents} payment event(s) in the retained window and no webhook ` +
        'endpoint to receive them. Stripe had nowhere to push, so nothing that ' +
        'should follow a payment ever ran.'];
    }
    return ['empty',
      'no webhook endpoint, and no payment events in the retained window either. ' +
      'Nothing has been lost yet: create the endpoint before the first real ' +
      'payment rather than after it.'];
  }
  if (enabled.length === 0) {
    return ['all-disabled',
      `${eps.length} endpoint(s) exist and every one of them is disabled, which ` +
      'delivers exactly as much as having none.'];
  }
  if (!livemode) {
    return ['test-mode',
      `${enabled.length} enabled endpoint(s), all test mode. A healthy test mode ` +
      'is what lets this ship: re-run with a live restricted key before ' +
      'concluding anything about production.'];
  }
  return ['covered', `${enabled.length} enabled endpoint(s) in this mode`];
}

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.status === 403) {
    throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

export function isLivemode(key) {
  return !/^(sk|rk|pk)_test_/.test(key);
}

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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  const { data: events = [] } = await get(key, '/events',
    { limit: 100, 'types[]': TRAFFIC_TYPES });

  let livemode = isLivemode(key);
  for (const obj of [...endpoints, ...events]) {
    if ('livemode' in obj) { livemode = Boolean(obj.livemode); break; }
  }

  const [state, detail] = verdict(endpoints, events.length, livemode);
  const line = `${state.padEnd(12)} ${detail}`;
  if (state === 'covered') {
    console.log(line);
    for (const ep of endpoints) {
      console.log(`  ${ep.url ?? '?'}  ${(ep.enabled_events ?? []).length} subscribed type(s)`);
    }
    return;
  }

  console.warn(line);
  if (state === 'blind' || state === 'empty') {
    console.warn(`  repair: POST ${API}/webhook_endpoints`);
    console.warn('    -d url=https://<your-domain>/stripe/webhook');
    console.warn('    -d enabled_events[]=payment_intent.succeeded');
    console.warn('    -d enabled_events[]=payment_intent.payment_failed');
    console.warn('  then copy the secret from the response into the server ' +
                 'environment: the whsec_ printed by the CLI is not it');
  }
  if (state === 'blind') {
    console.warn(`  backfill: GET ${API}/charges?created[gte]=<unix> and ` +
                 `${API}/invoices?created[gte]=<unix>, which are not retention ` +
                 'limited the way /v1/events is');
  }
  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

Two cases carry the note. An empty endpoint list on an account with payments is not the same finding as an empty list on an account with none, and a green result from a test key is not a result at all — it is the exact reassurance that let this reach production in the first place.

test_stripe_live_webhook_endpoints.py
from stripe_live_webhook_endpoints import verdict, is_livemode


def test_no_endpoints_with_payments_is_the_outage():
    state, detail = verdict([], 47, True)
    assert state == "blind"
    assert "47" in detail


def test_no_endpoints_and_no_traffic_is_a_gap_not_an_outage():
    state, detail = verdict([], 0, True)
    assert state == "empty"
    assert "before the first real payment" in detail


def test_endpoints_that_are_all_disabled_deliver_nothing():
    state, _ = verdict([{"status": "disabled"}, {"status": "disabled"}], 12, True)
    assert state == "all-disabled"


def test_a_healthy_test_mode_is_not_a_pass():
    state, detail = verdict([{"status": "enabled"}], 12, False)
    assert state == "test-mode"
    assert "live restricted key" in detail


def test_an_enabled_live_endpoint_is_covered():
    state, _ = verdict([{"status": "enabled"}], 12, True)
    assert state == "covered"


def test_missing_status_does_not_count_as_enabled():
    state, _ = verdict([{}], 0, True)
    assert state == "all-disabled"


def test_key_prefix_decides_the_mode():
    assert is_livemode("rk_live_abc") is True
    assert is_livemode("rk_test_abc") is False
stripe-live-webhook-endpoints.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, isLivemode } from './stripe-live-webhook-endpoints.mjs';

test('no endpoints with payments is the outage', () => {
  const [state, detail] = verdict([], 47, true);
  assert.equal(state, 'blind');
  assert.match(detail, /47/);
});

test('no endpoints and no traffic is a gap not an outage', () => {
  const [state, detail] = verdict([], 0, true);
  assert.equal(state, 'empty');
  assert.match(detail, /before the first real payment/);
});

test('endpoints that are all disabled deliver nothing', () => {
  const eps = [{ status: 'disabled' }, { status: 'disabled' }];
  assert.equal(verdict(eps, 12, true)[0], 'all-disabled');
});

test('a healthy test mode is not a pass', () => {
  const [state, detail] = verdict([{ status: 'enabled' }], 12, false);
  assert.equal(state, 'test-mode');
  assert.match(detail, /live restricted key/);
});

test('an enabled live endpoint is covered', () => {
  assert.equal(verdict([{ status: 'enabled' }], 12, true)[0], 'covered');
});

test('missing status does not count as enabled', () => {
  assert.equal(verdict([{}], 0, true)[0], 'all-disabled');
});

test('key prefix decides the mode', () => {
  assert.equal(isLivemode('rk_live_abc'), true);
  assert.equal(isLivemode('rk_test_abc'), false);
});

FAQ

Does stripe listen count as a webhook endpoint?

No. The CLI opens a temporary destination for as long as the process runs and prints its own signing secret. It creates no webhook endpoint object, so nothing about it survives the terminal session and nothing about it appears in GET /v1/webhook_endpoints.

I created an endpoint in the Dashboard and events still do not arrive.

Check which mode you were in when you created it. Test-mode and live-mode endpoints are separate objects and the Dashboard toggle is easy to miss. Listing endpoints with a live key is the unambiguous answer.

Can I recover the payments that came in before the endpoint existed?

Partly. Events are retained for 30 days, so anything inside that window can be replayed through your handler. Beyond it, reconcile from the objects instead: charges, invoices and subscriptions have no retention limit.

Which events should the new endpoint subscribe to?

The ones your handler has a branch for, listed explicitly. payment_intent.succeeded and payment_intent.payment_failed are the usual minimum, plus checkout.session.completed if you use Checkout. Do not open with a wildcard to be safe: it is a different problem, not a safer default.

Why does the signing secret matter here?

Because the whsec_ in your environment is probably the CLI's. Once the real endpoint exists it signs with its own secret, and a handler still verifying against the old one rejects every event with a 400, which after three days of retries gets the new endpoint disabled.

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.