Skip to content

Diagnostic Stripe

the endpoint listens for charge.succeeded, not payment_intent

This does not present as a webhook problem, because the webhook arrives. It presents as a data problem: the object in the payload has empty metadata, no customer, and nothing that maps the payment back to a cart or a user. The metadata is on the PaymentIntent. The endpoint is subscribed to the Charge.

Read-only key Python and Node.js Tests included
Cardboard boxes on wheels moving past apartment buildings.
Photo by Jimmy Liu on Unsplash
The short answer

Read enabled_events from GET /v1/webhook_endpoints. If it contains charge.succeeded and contains neither payment_intent.succeeded nor checkout.session.completed, the subscription predates the integration.

Confirm which era you are actually in with GET /v1/events?types[]=payment_intent.succeeded&types[]=checkout.session.completed. A non-empty result means the code moved to PaymentIntents or Checkout and the endpoint configuration never followed.

The problem in plain words

The handler runs, returns 200, and the endpoint stays enabled forever, so every health check you have says this integration is fine. What arrives is a Charge: it has an amount, a status and a payment method, and it does not have the metadata you attached when you created the PaymentIntent or the client_reference_id you set on the Checkout Session. Those live on the object you did not subscribe to.

The workaround people reach for makes it worse. The Charge has a payment_intent id, so the handler grows an extra fetch to go and get the missing fields, and now fulfilment depends on a second API call inside a webhook that Stripe is timing. It works until the day it does not.

Code moves tointentsmetadata on theintentEndpointunchangednot in the diffcharge.succeededfiresCharge in thepayloadmetadata emptyno cart, no userOrder unmatchedor fetched againlive
The handler returns 200 every time, so the endpoint stays healthy while nothing can be mapped back to an order.

Why it happens

The subscription is not in the repository. Migrating from Charges to PaymentIntents is a code change, and code changes get reviewed. enabled_events lives in the Stripe account, set through the Dashboard or a one-off script, so it never appears in the diff, never appears in the deploy, and nobody is reminded that it exists.

Charge and PaymentIntent are different objects with different fields. Metadata set on an intent is not copied down to the charge it creates, and client_reference_id exists only on the Checkout Session. Keying fulfilment on the Charge means keying it on the attempt rather than on the payment: an intent can go through several charge attempts, and the identifier your order table is holding is not any of them.

No charge.* subscription implies a Checkout event. Checkout completion arrives as checkout.session.completed, and for delayed-notification payment methods the money confirmation arrives later still as checkout.session.async_payment_succeeded. An endpoint subscribed only to charge events sees neither, so a Checkout integration built that way fulfils on a signal that does not carry the session.

Subscribing to both is not the safe middle ground. One payment then arrives twice in two shapes, and unless the handler keys its side effects on a payment identifier rather than an event, it runs fulfilment twice. The safe order is to add the new type, ship the branch, verify it, and only then remove the old one.

The fix, as a flow

The script reads each endpoint's subscription and then asks which events the account actually generates, because the same enabled_events list is correct on a Charges integration and broken on a PaymentIntents one.

enabled_events per endpointplus the types seen firingIntent or session eventsalignedCharge only, no intentsa real Charges integrationCharge only, intents fireone era behindSessions fire, unsubscribedCheckout never fulfilsCharge plus intentone payment handled twice
The traffic decides. Charge events with no intent traffic behind them are a legacy integration, not a stale subscription.

How to fix it

Read enabled_events and look for charge.succeeded

Do it per endpoint rather than on the union: the finding is that one endpoint is configured for the wrong era, and a second, newer endpoint elsewhere does not undo that.

Establish which era the account is actually in

GET /v1/events?types[]=payment_intent.succeeded&types[]=checkout.session.completed. If those types are firing, the integration is modern and the subscription is stale. If nothing comes back, you may genuinely still be creating Charges directly, which is a different problem with a different fix.

Decide where fulfilment belongs

Checkout and Payment Links fulfil on checkout.session.completed, with async_payment_succeeded and async_payment_failed for delayed-notification methods. A direct Elements integration fulfils on payment_intent.succeeded. Pick the one that carries the identifier your order table already stores.

Add the new type before removing the old one

Add the PaymentIntent or Checkout type, deploy the handler branch, and watch both fire for a day. Removing charge.succeeded first leaves a window with no fulfilment path at all; adding both permanently leaves you fulfilling twice.

Then drop charge.succeeded and re-run

Keep it only if you need charge-level data such as the balance transaction or the dispute linkage, and if you do, make it explicit that it is supplementary rather than a second fulfilment trigger.

How to check it worked

Re-run the script. The endpoint should report an aligned subscription for the era the account is in.

python3 stripe_charge_event_drift.py
# aligned   https://example.com/stripe/webhook  fulfilment events match the integration

The full code

Two GETs and no writes — the endpoints, and a type-filtered read of the events to establish which era the account is really in. The classifier takes both, because charge.succeeded on its own is not a fault: on an account that genuinely still creates Charges it is correct, and the same configuration on an account where PaymentIntents are firing is the bug.

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_charge_event_drift.py
"""Report webhook endpoints subscribed to charge events on a PaymentIntent integration.

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_charge_event_drift")

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

CHARGE = "charge.succeeded"
INTENT = "payment_intent.succeeded"
SESSION = "checkout.session.completed"


def verdict(enabled_events, fired_types):
    """Classify one endpoint's fulfilment subscription. Pure and testable.

    `fired_types` is the set of modern success types actually seen in the
    retained window. Checked in order of how much fulfilment they cost you:
    a missing path first, then a duplicated one. Returns (state, detail).
    """
    events = set(enabled_events or [])
    fired = set(fired_types or [])
    modern = INTENT in events or SESSION in events

    if "*" in events:
        return ("wildcard",
                "a wildcard delivers both shapes of the same payment. Fulfilment "
                "has to pick one and ignore the other, explicitly.")

    if CHARGE in events and not modern:
        if fired & {INTENT, SESSION}:
            return ("stale",
                    "%s is the only success subscription, but %s fired in the "
                    "retained window. The Charge carries neither the intent "
                    "metadata nor client_reference_id."
                    % (CHARGE, ", ".join(sorted(fired & {INTENT, SESSION}))))
        return ("legacy",
                "%s only, and no PaymentIntent or Checkout events fired. This "
                "looks like a genuine Charges API integration rather than a "
                "stale subscription." % CHARGE)

    if SESSION in fired and SESSION not in events:
        return ("checkout-gap",
                "Checkout Sessions are completing and %s is not subscribed. No "
                "charge or payment_intent subscription implies it." % SESSION)

    if CHARGE in events and modern:
        return ("overlapping",
                "%s and the fulfilment event are both subscribed, so one payment "
                "arrives twice in two shapes. Fulfil on one and drop the other."
                % CHARGE)

    return ("aligned", "fulfilment events match the integration")


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 modern_types_seen(session):
    """Which of the modern success types have fired in the retained window."""
    page = get(session, "/events", limit=100, **{"types[]": [INTENT, SESSION]})
    return {ev.get("type") for ev in page.get("data", [])}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--quiet-aligned", action="store_true",
                    help="print only the endpoints that need attention")
    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", [])
    if not endpoints:
        log.info("no webhook endpoints configured for this key's mode")
        return 0

    fired = modern_types_seen(s)
    log.info("modern success types seen: %s", ", ".join(sorted(fired)) or "none")

    bad = 0
    for ep in endpoints:
        state, detail = verdict(ep.get("enabled_events"), fired)
        line = "%-12s %s  %s" % (state, ep.get("url", "?"), detail)
        if state in ("aligned", "legacy"):
            if not args.quiet_aligned:
                log.info(line)
            continue
        bad += 1
        log.warning(line)
        want = SESSION if SESSION in fired else INTENT
        log.warning("  repair: add enabled_events[]=%s to %s/webhook_endpoints/%s, "
                    "ship the handler branch, then remove %s",
                    want, API, ep["id"], CHARGE)

    log.info("%d endpoint(s), %d needing attention", len(endpoints), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-charge-event-drift.mjs
/**
 * Report webhook endpoints subscribed to charge events on a PaymentIntent integration.
 *
 * 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 CHARGE = 'charge.succeeded';
const INTENT = 'payment_intent.succeeded';
const SESSION = 'checkout.session.completed';

/**
 * Classify one endpoint's fulfilment subscription. Pure and testable.
 * Checked in order of how much fulfilment they cost you: a missing path first,
 * then a duplicated one.
 */
export function verdict(enabledEvents, firedTypes) {
  const events = new Set(enabledEvents ?? []);
  const fired = new Set(firedTypes ?? []);
  const modern = events.has(INTENT) || events.has(SESSION);

  if (events.has('*')) {
    return ['wildcard',
      'a wildcard delivers both shapes of the same payment. Fulfilment has to ' +
      'pick one and ignore the other, explicitly.'];
  }

  if (events.has(CHARGE) && !modern) {
    const seen = [INTENT, SESSION].filter((t) => fired.has(t));
    if (seen.length > 0) {
      return ['stale',
        `${CHARGE} is the only success subscription, but ${seen.join(', ')} ` +
        'fired in the retained window. The Charge carries neither the intent ' +
        'metadata nor client_reference_id.'];
    }
    return ['legacy',
      `${CHARGE} only, and no PaymentIntent or Checkout events fired. This ` +
      'looks like a genuine Charges API integration rather than a stale subscription.'];
  }

  if (fired.has(SESSION) && !events.has(SESSION)) {
    return ['checkout-gap',
      `Checkout Sessions are completing and ${SESSION} is not subscribed. No ` +
      'charge or payment_intent subscription implies it.'];
  }

  if (events.has(CHARGE) && modern) {
    return ['overlapping',
      `${CHARGE} and the fulfilment event are both subscribed, so one payment ` +
      'arrives twice in two shapes. Fulfil on one and drop the other.'];
  }

  return ['aligned', 'fulfilment events match the integration'];
}

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 modernTypesSeen(key) {
  const page = await get(key, '/events', { limit: 100, 'types[]': [INTENT, SESSION] });
  return new Set((page.data ?? []).map((ev) => ev.type));
}

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 });
  if (endpoints.length === 0) {
    console.log("no webhook endpoints configured for this key's mode");
    return;
  }

  const fired = await modernTypesSeen(key);
  console.log(`modern success types seen: ${[...fired].sort().join(', ') || 'none'}`);

  let bad = 0;
  for (const ep of endpoints) {
    const [state, detail] = verdict(ep.enabled_events, fired);
    const line = `${state.padEnd(12)} ${ep.url ?? '?'}  ${detail}`;
    if (state === 'aligned' || state === 'legacy') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    const want = fired.has(SESSION) ? SESSION : INTENT;
    console.warn(`  repair: add enabled_events[]=${want} to ` +
                 `${API}/webhook_endpoints/${ep.id}, ship the handler branch, ` +
                 `then remove ${CHARGE}`);
  }

  console.log(`${endpoints.length} endpoint(s), ${bad} needing attention`);
  process.exitCode = bad ? 1 : 0;
}

// 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 pair worth pinning is stale against legacy. The endpoint configuration is byte-for-byte identical in both; the only thing that separates a broken integration from a correct one is whether PaymentIntent or Checkout events are firing on the account. A classifier that looks at enabled_events alone reports the same verdict for both, and one of those verdicts is wrong.

test_stripe_charge_event_drift.py
from stripe_charge_event_drift import verdict


def test_charge_only_while_intents_fire_is_stale():
    state, detail = verdict(["charge.succeeded"], ["payment_intent.succeeded"])
    assert state == "stale"
    assert "client_reference_id" in detail


def test_the_same_config_with_no_modern_traffic_is_a_real_charges_integration():
    # Identical enabled_events. Only the account's traffic tells them apart.
    state, _ = verdict(["charge.succeeded"], [])
    assert state == "legacy"


def test_subscribing_to_both_is_double_fulfilment():
    state, detail = verdict(["charge.succeeded", "payment_intent.succeeded"],
                            ["payment_intent.succeeded"])
    assert state == "overlapping"
    assert "twice" in detail


def test_checkout_sessions_firing_with_no_session_subscription():
    state, _ = verdict(["payment_intent.succeeded"],
                       ["payment_intent.succeeded", "checkout.session.completed"])
    assert state == "checkout-gap"


def test_a_matching_subscription_is_aligned():
    state, _ = verdict(["payment_intent.succeeded", "checkout.session.completed"],
                       ["payment_intent.succeeded", "checkout.session.completed"])
    assert state == "aligned"


def test_a_wildcard_is_called_out_rather_than_passed():
    state, _ = verdict(["*"], ["payment_intent.succeeded"])
    assert state == "wildcard"
stripe-charge-event-drift.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-charge-event-drift.mjs';

test('charge only while intents fire is stale', () => {
  const [state, detail] = verdict(['charge.succeeded'], ['payment_intent.succeeded']);
  assert.equal(state, 'stale');
  assert.match(detail, /client_reference_id/);
});

test('the same config with no modern traffic is a real charges integration', () => {
  assert.equal(verdict(['charge.succeeded'], [])[0], 'legacy');
});

test('subscribing to both is double fulfilment', () => {
  const [state, detail] = verdict(
    ['charge.succeeded', 'payment_intent.succeeded'], ['payment_intent.succeeded']);
  assert.equal(state, 'overlapping');
  assert.match(detail, /twice/);
});

test('checkout sessions firing with no session subscription', () => {
  const [state] = verdict(['payment_intent.succeeded'],
    ['payment_intent.succeeded', 'checkout.session.completed']);
  assert.equal(state, 'checkout-gap');
});

test('a matching subscription is aligned', () => {
  const [state] = verdict(['payment_intent.succeeded', 'checkout.session.completed'],
    ['payment_intent.succeeded', 'checkout.session.completed']);
  assert.equal(state, 'aligned');
});

test('a wildcard is called out rather than passed', () => {
  assert.equal(verdict(['*'], ['payment_intent.succeeded'])[0], 'wildcard');
});

FAQ

What is the practical difference between charge.succeeded and payment_intent.succeeded?

The object in the payload. charge.succeeded carries a Charge, which is one attempt at a payment; payment_intent.succeeded carries the PaymentIntent, which is the payment. Metadata you set on the intent is not copied onto the charge, so a handler reading charge.metadata finds an empty object and cannot map the payment to anything.

Why is my Charge metadata empty when I definitely set metadata?

Because you set it on the PaymentIntent or the Checkout Session. Those are separate objects with separate metadata bags. The Charge does carry a payment_intent id, so the data is reachable with another API call, but that turns fulfilment into a second request inside a timed webhook.

Should I subscribe to both charge.succeeded and payment_intent.succeeded?

Only during the migration, and only if your handler is idempotent on a payment identifier rather than an event id. Left permanently, one payment arrives twice in two shapes and any side effect that is not deduplicated runs twice.

Which event should a Checkout integration fulfil on?

checkout.session.completed, which carries the session with client_reference_id and the line items. For delayed-notification payment methods the money is not confirmed at that point, and the follow-up arrives as checkout.session.async_payment_succeeded or async_payment_failed.

Is charge.succeeded ever the right subscription?

Yes, if you actually create Charges directly, or if you need charge-level detail such as the balance transaction or the linkage a dispute will later reference. Treat it as supplementary in that case, and make sure only one subscription triggers fulfilment.

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.