Diagnostic Stripe
a Connect platform has no endpoint for connected accounts
The endpoint is enabled. It is subscribed to account.updated. It has been in the dashboard for two years and it delivers thousands of events a week without a single failure. And it has never once received an event about a connected account, because the thing that decides whether it does is not in enabled_events and is not returned on the object you are looking at.
A Connect-scoped destination is a different object from an account-scoped one. Events raised by a connected account carry a top-level account property and are delivered only to an endpoint created with connect=true (in Workbench: an event destination listening to Connected accounts). Subscribing an ordinary endpoint to account.updated does not make it receive them.
The catch when you go to check: the endpoint object does not return the connect flag. Only application, status, url and enabled_events come back. So the read-only check is a proxy — confirm the account is a platform with GET /v1/accounts?limit=1, then look for any enabled endpoint subscribed to account.updated or account.application.deauthorized. Absence is proof of a gap. Presence is only evidence of intent.
The problem in plain words
What this looks like day to day is a platform whose picture of its sellers is frozen at the moment each one finished onboarding. A seller's verification lapses and charges_enabled goes false; your database still says active, your UI still shows them live, and their customers hit a checkout that errors. A seller disconnects the platform from their own Stripe dashboard; you keep listing them, keep routing payments at them, keep counting them in the seller total.
The reason it survives so long is that nothing looks broken. Your platform's own events — charges, payouts, subscriptions on your account — arrive perfectly. The delivery success rate on the endpoint is 100%, because Stripe is not attempting the deliveries you are missing. There is no failed delivery to find, no retry to inspect, no error rate to alert on. The events simply were never addressed to you.
Why it happens
The scope is set once, at creation, and is invisible afterwards. connect=true is a creation parameter. It is not returned when you list endpoints and there is no field on the object that reflects it, so no amount of reading GET /v1/webhook_endpoints will tell you directly whether your Connect events have a home. This is the whole reason the check has to be indirect.
enabled_events accepts the subscription either way. You can subscribe an account-scoped endpoint to account.updated and Stripe accepts it without complaint — it is a legitimate subscription, since your own account can be updated too. The list looks right. It just never fills with connected-account traffic.
Nothing ever errors. Undelivered events, disabled endpoints and signature failures all leave a trace somewhere. An event with no matching destination leaves none. There is no counter of events you were not sent.
The two events fail differently, so half a subscription is its own bug. account.updated carries verification and capability changes. account.application.deauthorized is the only signal that a seller has disconnected. A platform with the first and not the second sees every requirement problem and none of the departures, which is how a disconnected seller stays in the active list for a year.
The fix, as a flow
The script cannot read the scope of a destination, because Stripe does not return it. So it reads the one thing that is returned, the subscribed event types, and reports the gap it can prove rather than the one it can guess.
How to fix it
Establish that this check applies at all
GET /v1/accounts?limit=1. If it comes back empty, this is not a platform and there is nothing to scope. Running the Connect coverage check against a plain account produces a permanent warning that everybody learns to ignore, which then hides the day it becomes true.
Union the subscriptions across enabled endpoints only
A disabled endpoint delivers nothing, so a disabled endpoint holding the only account.updated subscription is the same as not having one. Count them separately and mention them, because "we do have that endpoint" is the first thing anyone will say.
Treat a wildcard as unknown, not as covered
An endpoint subscribed to * would receive connected-account events if it were Connect scoped, and the API will not say whether it is. That is genuinely inconclusive and should be reported as such rather than being counted either way. Open the destination in Workbench and read whether it listens to your account or to connected accounts.
Check both modes, and remember the events are the same either way
Test-mode and live-mode endpoints are separate objects. A team that built Connect handling against a test-mode Connect destination and then created the live endpoint through a different route ends up with exactly the asymmetry this check finds.
Create the destination and handle event.account
The new endpoint needs connect=true and a handler that reads the top-level account property, because an incoming account.updated now tells you which seller changed. Any API call you make in response has to be made as that account, with the Stripe-Account header.
How to check it worked
Re-run the script. A platform should report covered, and the state should stay covered after the next endpoint is added.
python3 stripe_connect_webhook_scope.py
# 3 endpoint(s), platform with connected accounts: covered
The full code
Two GET requests: one page of accounts to establish the account is a platform, and the list of webhook endpoints. The classifier is pure and, more importantly, honest — it returns inconclusive when the API genuinely cannot answer, because a check that guesses in that case is worse than one that says so.
"""Report a Connect platform with no webhook destination scoped to its accounts.
Read only. Two GET requests and no writes: give this a RESTRICTED key with read
access to Webhook Endpoints and Connected accounts. 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_connect_webhook_scope")
API = "https://api.stripe.com/v1"
# The two events that only ever come from a connected account. The endpoint
# object does not return whether it was created with connect=true, so a
# subscription to one of these is the closest thing to evidence the API offers:
# their absence proves a gap, their presence only shows somebody meant to.
CONNECT_SIGNALS = ("account.updated", "account.application.deauthorized")
def coverage(endpoints, is_platform):
"""Decide whether connected-account events have anywhere to go. Pure.
Takes the raw /v1/webhook_endpoints list and whether this account has any
connected accounts. Returns (state, detail). `inconclusive` is a real answer
here and not a failure of the check: a wildcard endpoint would receive these
events if it were Connect scoped, and nothing in the API says whether it is.
"""
if not is_platform:
return ("not-a-platform",
"no connected accounts on this key, so there is no Connect traffic "
"to scope a destination for")
enabled = [e for e in endpoints if e.get("status") == "enabled"]
disabled = len(endpoints) - len(enabled)
if not enabled:
return ("no-endpoints",
"no enabled endpoint in this mode at all (%d disabled): nothing is "
"being delivered anywhere, connected or otherwise" % disabled)
subscribed = set()
wildcards = []
for e in enabled:
types = e.get("enabled_events") or []
if "*" in types:
wildcards.append(e.get("url") or e.get("id") or "?")
subscribed.update(types)
have = [s for s in CONNECT_SIGNALS if s in subscribed]
if len(have) == len(CONNECT_SIGNALS):
return ("covered",
"an enabled endpoint subscribes to %s" % " and ".join(CONNECT_SIGNALS))
if wildcards and not have:
return ("inconclusive",
"%d endpoint(s) subscribe to * and the endpoint object never returns "
"whether they are Connect scoped: open %s in Workbench and read "
"whether it listens to your account or to connected accounts"
% (len(wildcards), wildcards[0]))
if have:
missing = [s for s in CONNECT_SIGNALS if s not in subscribed]
return ("thin",
"%s is subscribed but %s is not: %s"
% (have[0], missing[0],
"sellers who disconnect keep looking active"
if missing[0] == "account.application.deauthorized"
else "you see the departures and none of the verification failures"))
tail = ", and %d disabled endpoint(s) were ignored" % disabled if disabled else ""
return ("uncovered",
"no enabled endpoint subscribes to %s%s"
% (" or ".join(CONNECT_SIGNALS), tail))
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 endpoints(session):
"""Return every webhook endpoint, paginated. Usually one page; not always."""
out = []
params = {"limit": 100}
while True:
page = get(session, "/webhook_endpoints", **params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more"):
return out
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--show-endpoints", action="store_true",
help="print every endpoint with its status and subscription count")
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})
eps = endpoints(s)
is_platform = bool(get(s, "/accounts", limit=1).get("data"))
if args.show_endpoints:
for e in eps:
log.info("%s %-8s %3d event type(s) %s",
e.get("id", "we_?"), e.get("status", "?"),
len(e.get("enabled_events") or []), e.get("url", ""))
state, detail = coverage(eps, is_platform)
log.info("%d endpoint(s), %s: %s",
len(eps), "platform with connected accounts" if is_platform
else "no connected accounts", state)
if state in ("covered", "not-a-platform"):
log.info(" %s", detail)
return 0
log.warning(" %s", detail)
log.warning(" repair: create a second destination scoped to connected accounts:")
log.warning(" POST %s/webhook_endpoints with connect=true, "
"url=https://<yourdomain>/stripe/connect-webhook", API)
log.warning(" enabled_events[]=account.updated "
"enabled_events[]=account.application.deauthorized "
"enabled_events[]=capability.updated "
"enabled_events[]=person.updated "
"enabled_events[]=payout.failed")
log.warning(" in Workbench: Create an event destination, then Connected accounts")
log.warning(" then: read the top-level account property on each event and make "
"any follow-up call as that account")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report a Connect platform with no webhook destination scoped to its accounts.
*
* Read only. Two GET requests and no writes: give this a RESTRICTED key with
* read access to Webhook Endpoints and Connected accounts. The repair is
* printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// The two events that only ever come from a connected account. The endpoint
// object does not return whether it was created with connect=true, so their
// absence proves a gap and their presence only shows somebody meant to.
const CONNECT_SIGNALS = ['account.updated', 'account.application.deauthorized'];
/**
* Decide whether connected-account events have anywhere to go. Pure.
* Returns [state, detail]. `inconclusive` is a real answer, not a failure.
*/
export function coverage(endpoints, isPlatform) {
if (!isPlatform) {
return ['not-a-platform',
'no connected accounts on this key, so there is no Connect traffic to ' +
'scope a destination for'];
}
const enabled = endpoints.filter((e) => e.status === 'enabled');
const disabled = endpoints.length - enabled.length;
if (enabled.length === 0) {
return ['no-endpoints',
`no enabled endpoint in this mode at all (${disabled} disabled): nothing ` +
'is being delivered anywhere, connected or otherwise'];
}
const subscribed = new Set();
const wildcards = [];
for (const e of enabled) {
const types = e.enabled_events ?? [];
if (types.includes('*')) wildcards.push(e.url ?? e.id ?? '?');
for (const t of types) subscribed.add(t);
}
const have = CONNECT_SIGNALS.filter((s) => subscribed.has(s));
if (have.length === CONNECT_SIGNALS.length) {
return ['covered', `an enabled endpoint subscribes to ${CONNECT_SIGNALS.join(' and ')}`];
}
if (wildcards.length && have.length === 0) {
return ['inconclusive',
`${wildcards.length} endpoint(s) subscribe to * and the endpoint object ` +
'never returns whether they are Connect scoped: open ' +
`${wildcards[0]} in Workbench and read whether it listens to your account ` +
'or to connected accounts'];
}
if (have.length) {
const missing = CONNECT_SIGNALS.filter((s) => !subscribed.has(s));
const consequence = missing[0] === 'account.application.deauthorized'
? 'sellers who disconnect keep looking active'
: 'you see the departures and none of the verification failures';
return ['thin', `${have[0]} is subscribed but ${missing[0]} is not: ${consequence}`];
}
const tail = disabled ? `, and ${disabled} disabled endpoint(s) were ignored` : '';
return ['uncovered',
`no enabled endpoint subscribes to ${CONNECT_SIGNALS.join(' or ')}${tail}`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) 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();
}
async function endpoints(key) {
const out = [];
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/webhook_endpoints', params);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more) return out;
params.starting_after = data[data.length - 1].id;
}
}
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 eps = await endpoints(key);
const accounts = await get(key, '/accounts', { limit: 1 });
const isPlatform = (accounts.data ?? []).length > 0;
const [state, detail] = coverage(eps, isPlatform);
console.log(`${eps.length} endpoint(s), ` +
`${isPlatform ? 'platform with connected accounts' : 'no connected accounts'}: ${state}`);
if (state === 'covered' || state === 'not-a-platform') {
console.log(` ${detail}`);
return;
}
console.warn(` ${detail}`);
console.warn(' repair: create a second destination scoped to connected accounts:');
console.warn(` POST ${API}/webhook_endpoints with connect=true, ` +
'url=https://<yourdomain>/stripe/connect-webhook');
console.warn(' enabled_events[]=account.updated ' +
'enabled_events[]=account.application.deauthorized ' +
'enabled_events[]=capability.updated ' +
'enabled_events[]=person.updated enabled_events[]=payout.failed');
console.warn(' in Workbench: Create an event destination, then Connected accounts');
console.warn(' then: read the top-level account property on each event and make ' +
'any follow-up call as that account');
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 interesting tests are the ones about what the check cannot know. A disabled endpoint holding the only relevant subscription has to count as a gap, and a wildcard endpoint has to come back as inconclusive rather than being quietly counted as coverage — that is the difference between a check that reports the truth and one that reports a guess.
from stripe_connect_webhook_scope import coverage
def endpoint(events, status="enabled", url="https://example.com/hook"):
return {"id": "we_1", "status": status, "url": url, "enabled_events": events}
def test_a_plain_account_is_not_asked_about_connect_scope():
state, detail = coverage([endpoint(["charge.succeeded"])], False)
assert state == "not-a-platform"
assert "no connected accounts" in detail
def test_both_connect_signals_present_is_covered():
state, _ = coverage(
[endpoint(["account.updated", "account.application.deauthorized"])], True)
assert state == "covered"
def test_neither_signal_is_uncovered():
state, detail = coverage([endpoint(["charge.succeeded", "payout.paid"])], True)
assert state == "uncovered"
assert "account.updated" in detail
def test_a_disabled_endpoint_does_not_count_as_coverage():
# A disabled endpoint delivers nothing, so it is the same as not having one.
# It still gets mentioned, because "we do have that endpoint" is the first
# thing anybody says.
state, detail = coverage(
[endpoint(["charge.succeeded"]),
endpoint(["account.updated", "account.application.deauthorized"],
status="disabled")], True)
assert state == "uncovered"
assert "1 disabled endpoint(s) were ignored" in detail
def test_a_wildcard_is_inconclusive_rather_than_covered():
# A wildcard endpoint would receive these events if it were Connect scoped,
# and the object does not say whether it is. Reporting that honestly is the
# whole point.
state, detail = coverage([endpoint(["*"])], True)
assert state == "inconclusive"
assert "Workbench" in detail
def test_account_updated_without_deauthorized_is_half_a_subscription():
state, detail = coverage([endpoint(["account.updated"])], True)
assert state == "thin"
assert "disconnect" in detail
def test_no_enabled_endpoint_at_all_says_so_first():
state, detail = coverage([endpoint(["*"], status="disabled")], True)
assert state == "no-endpoints"
assert "nothing is being delivered anywhere" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coverage } from './stripe-connect-webhook-scope.mjs';
const endpoint = (events, status = 'enabled') => ({
id: 'we_1', status, url: 'https://example.com/hook', enabled_events: events,
});
test('a plain account is not asked about connect scope', () => {
const [state, detail] = coverage([endpoint(['charge.succeeded'])], false);
assert.equal(state, 'not-a-platform');
assert.match(detail, /no connected accounts/);
});
test('both connect signals present is covered', () => {
const [state] = coverage(
[endpoint(['account.updated', 'account.application.deauthorized'])], true);
assert.equal(state, 'covered');
});
test('neither signal is uncovered', () => {
const [state, detail] = coverage([endpoint(['charge.succeeded', 'payout.paid'])], true);
assert.equal(state, 'uncovered');
assert.match(detail, /account.updated/);
});
test('a disabled endpoint does not count as coverage', () => {
// A disabled endpoint delivers nothing, so it is the same as not having one.
const [state, detail] = coverage(
[endpoint(['charge.succeeded']),
endpoint(['account.updated', 'account.application.deauthorized'], 'disabled')], true);
assert.equal(state, 'uncovered');
assert.match(detail, /1 disabled endpoint\(s\) were ignored/);
});
test('a wildcard is inconclusive rather than covered', () => {
const [state, detail] = coverage([endpoint(['*'])], true);
assert.equal(state, 'inconclusive');
assert.match(detail, /Workbench/);
});
test('account.updated without deauthorized is half a subscription', () => {
const [state, detail] = coverage([endpoint(['account.updated'])], true);
assert.equal(state, 'thin');
assert.match(detail, /disconnect/);
});
test('no enabled endpoint at all says so first', () => {
const [state, detail] = coverage([endpoint(['*'], 'disabled')], true);
assert.equal(state, 'no-endpoints');
assert.match(detail, /nothing is being delivered anywhere/);
});
FAQ
Why can I not just read the connect flag on the endpoint?
Because it is not returned. connect is a creation parameter, and the webhook endpoint object comes back with id, url, status, enabled_events, api_version, secret and application, but no field describing its scope. That is why this check works from the subscribed event types instead, and why it reports inconclusive when a wildcard makes the subscription uninformative.
Does an account-scoped endpoint ever receive account.updated?
Yes, for your own account. Your platform account can be updated too, and that event goes to your normal endpoint. This is exactly what makes the subscription look correct: the event type is legitimate on both kinds of destination, and only the ones carrying a top-level account property are restricted to the Connect-scoped one.
Can one endpoint serve both my account and my connected accounts?
No. The scope is fixed when the destination is created, so a platform needs two: one for its own events and one for its accounts'. They can point at the same URL if your handler branches on whether the event carries an account property, but they are two objects with two signing secrets.
What do I do with the account property once events arrive?
Treat it as the identity of the seller the event is about, and make every follow-up API call as that account by setting the Stripe-Account header. Reading the account object without that header returns your platform's account, which is the classic way a newly built Connect handler appears to work and updates the wrong record.
Does this need a live secret key?
No. A restricted key with read access to Webhook Endpoints and Connected accounts is enough. The script lists endpoints and reads one page of accounts to establish that the check applies; it cannot create a destination, and prints the call for you to run instead.
Related field notes
- payout.failed is unsubscribed so broken bank details go unseen
- A connected account sits with charges_enabled false
- Live mode has no webhook endpoint at all
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.
- Connect webhooks — Stripe Docs
- The webhook endpoint object — Stripe API reference
- Create a webhook endpoint — Stripe API reference
- Receive Stripe events in your webhook endpoint — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.