Skip to content

Diagnostic Stripe

no v2 event destination exists, so thin events never arrive

The feature you turned on documents an event with a v1. prefix in its name. Somebody added it to the webhook endpoint, saved, and nothing happened — no delivery, no failure, no entry in the endpoint's log. The event is real and it is being generated. It is being handed to a kind of destination your account does not have.

Read-only key Python and Node.js Tests included
Teal led panel
Photo by Adi Goldstein on Unsplash
The short answer

Call GET /v2/core/event_destinations with a Stripe-Version that supports v2. If data is empty, or no element has event_payload of "thin", then no thin event can reach you no matter what is listed on your /v1/webhook_endpoints.

Thin events are delivered only to a v2 event destination. It is a different object, created at a different endpoint, with its own signing secret and its own status. A snapshot (v1) endpoint cannot carry them, and adding one to enabled_events there does not make it try.

The problem in plain words

Nothing about this failure looks like a failure. The endpoint you configured is enabled and healthy; it is delivering every event it has ever delivered. The new event type sits in its subscription list looking exactly like the others. Stripe is generating the event on schedule. Every component reports success and the handler never runs.

What makes it hard to see is the naming. The events are called things like v1.billing.meter.error_report_triggered — a v1 prefix on an event that belongs to the v2 delivery system, because the prefix describes the version of the resource the event is about, not the API that delivers it. Reading that name, the obvious conclusion is that it belongs on the v1 endpoint you already have. It does not.

Feature turnedonbilling metersEvent namedv1.*read as a v1 eventAdded to v1endpointsaved with noerrorThin eventemittedneeds a v2destinationNothingdeliveredhandler never runs
The v1 prefix in the event name describes the resource, not the delivery system, and every step of the wrong setup returns success.

Why it happens

Thin events and snapshot events are two different products. A snapshot event carries a full object payload rendered at a pinned API version, which is what /v1/webhook_endpoints has always delivered. A thin event carries an id, a type and a pointer; you fetch the related object yourself at whatever version you speak today. Those are different payload contracts, so they were given different destinations rather than one destination with a flag.

The v2 destination is created somewhere your integration has never been. POST /v2/core/event_destinations is a separate endpoint under a separate API surface. If your code, your Terraform and your runbook only know /v1/webhook_endpoints, there is nothing in your repository that would ever produce one, and nothing that would notice its absence.

Newer features are the ones that use it. Billing meters, Accounts v2 and the money-management APIs emit thin events. So the gap only bites the day somebody adopts one of those, which is long after the webhook integration was written and reviewed, by which point it is treated as finished.

A destination can exist and still be off. The v2 object carries its own status and status_details. A destination that was created and then disabled after repeated failures reads as configured to anyone counting rows, and delivers nothing, which is the same outcome as never having created it.

The fix, as a flow

The script asks the v2 list endpoint what destinations exist, then asks one v1 endpoint whether anything on the account emits thin events yet, because an empty list is a gap on one account and an outage on another.

GET/v2/core/event_destinationsplus one probe for v2 features inuseEnabled thin destinationcoveredThin but disabledfix the handler, re-enableOnly snapshot payloadscreate a thin destinationNone, v2 feature liveevents dropping nowNone, nothing emittinga gap to close
Counting destinations is not the check. A destination that carries snapshot payloads cannot carry a thin event at all.

How to fix it

List v2 destinations with a version that knows about them

GET /v2/core/event_destinations and send Stripe-Version explicitly. A key pinned to an older account default will get an error from this path rather than an empty list, and an error here means "your version cannot see v2", not "there is nothing there".

Check event_payload, not just the count

A destination with event_payload of "snapshot" is a v2-shaped object delivering v1-shaped events. It is a perfectly reasonable thing to have and it will not carry a single thin event. Only "thin" counts for this check.

Read status and status_details on the ones that do exist

The same disable-after-failure behaviour applies here as on a v1 endpoint. status_details is where Stripe records why, and it is the difference between a destination you need to create and one you need to fix.

Confirm you are actually using a v2 feature

GET /v1/billing/meters?limit=1 returning data means thin events are being generated right now and dropped. An account with no v2 feature in use has the same gap and is not yet losing anything, which is the difference between a ticket and an incident.

Create the destination and parse the thin payload properly

A thin event body is small on purpose. Verify it, call parse_event_notification(), then fetchRelatedObject() to pull the object at your own API version. Copying the v1 handler across and reading event.data.object will find nothing there.

How to check it worked

Re-run the script. One enabled destination with event_payload of thin is all this check wants to see.

python3 stripe_v2_event_destinations.py
# covered          ed_61RVAaB is enabled and takes thin events

The full code

Two GETs and no writes — one against the v2 list endpoint, one against Billing Meters to establish whether the gap is currently costing anything. A restricted key with read access to Event Destinations and Billing Meters is enough. The classifier is pure and takes the destination list plus that one boolean, because the same empty list means two different things depending on it.

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_v2_event_destinations.py
"""Report a Stripe account with no v2 event destination for thin events.

Read only. Two GETs and no writes: give this a RESTRICTED key with read access to
Event Destinations and Billing Meters. 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_v2_event_destinations")

API_V1 = "https://api.stripe.com/v1"
API_V2 = "https://api.stripe.com/v2"

# Sent explicitly. The account default may predate v2 entirely, in which case the
# v2 path errors instead of returning an empty list, and the two look nothing
# alike once you know to tell them apart.
DEFAULT_VERSION = "2025-03-31.basil"


def verdict(destinations, v2_feature_in_use=False):
    """Classify an account's v2 event destinations. Pure, so it is testable offline.

    `destinations` is the list from /v2/core/event_destinations. `v2_feature_in_use`
    says whether anything on the account is currently generating thin events, which
    is what separates a gap from an outage. Returns (state, detail).
    """
    dests = list(destinations or [])
    thin = [d for d in dests if d.get("event_payload") == "thin"]
    enabled = [d for d in thin if d.get("status") == "enabled"]
    if enabled:
        return ("covered",
                "%s is enabled and takes thin events"
                % enabled[0].get("id", "<no id>"))
    if thin:
        d = thin[0]
        return ("disabled",
                "%s takes thin events but its status is %r: %s"
                % (d.get("id", "<no id>"), d.get("status"),
                   d.get("status_details") or "no status_details given"))
    if dests:
        return ("snapshot-only",
                "%d event destination(s) exist and every one of them is "
                "event_payload=snapshot, which cannot carry a thin event"
                % len(dests))
    if v2_feature_in_use:
        return ("dropping",
                "no v2 event destination at all, and a v2 feature is in use: the "
                "thin events it emits are being generated and delivered nowhere")
    return ("none",
            "no v2 event destination exists. Nothing emits thin events yet, so "
            "nothing is being lost today.")


def get(session, url, params=None, version=None):
    headers = {"Stripe-Version": version} if version else {}
    r = session.get(url, params=params or {}, headers=headers, 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 in (400, 404) and "/v2/" in url:
        raise SystemExit(
            "%d from %s: this key or API version cannot see v2 resources. Retry "
            "with --api-version set to a version that supports v2." % (r.status_code, url))
    r.raise_for_status()
    return r.json()


def event_destinations(session, version):
    """Every v2 event destination.

    The v2 list endpoints paginate with an absolute `next_page_url` rather than the
    `starting_after` cursor the v1 list endpoints use, so the loop follows the URL
    Stripe hands back instead of building one.
    """
    out = []
    url = API_V2 + "/core/event_destinations"
    params = {"limit": 100}
    while url:
        page = get(session, url, params, version)
        out.extend(page.get("data", []))
        url = page.get("next_page_url")
        params = None
    return out


def v2_feature_in_use(session):
    """One cheap probe: does anything on this account emit thin events yet?

    Billing meters are the most common entry point. A different v2 feature would
    need its own probe; the point of the flag is only to separate a gap that costs
    nothing today from one that is losing events right now.
    """
    page = get(session, API_V1 + "/billing/meters", {"limit": 1})
    return bool(page.get("data"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--api-version", default=DEFAULT_VERSION,
                    help="Stripe-Version to send on the v2 request")
    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})

    dests = event_destinations(s, args.api_version)
    in_use = v2_feature_in_use(s)
    state, detail = verdict(dests, in_use)

    log.info("%-16s %s", state, detail)
    for d in dests:
        log.info("  %s  payload=%s  status=%s  events_from=%s",
                 d.get("id"), d.get("event_payload"), d.get("status"),
                 d.get("events_from"))
    if state == "covered":
        return 0

    if state == "disabled":
        log.warning("  repair: fix the handler, then re-enable the destination at "
                    "%s/core/event_destinations/<id>/enable", API_V2)
        return 1

    log.warning("  repair: create a thin destination (a separate object from any "
                "/v1/webhook_endpoints you already have):")
    log.warning("  POST %s/core/event_destinations -d type=webhook_endpoint "
                "-d event_payload=thin -d \"events_from[]=@self\" "
                "-d \"enabled_events[]=v1.billing.meter.error_report_triggered\" "
                "-d webhook_endpoint[url]=https://<yourdomain>/stripe/thin-webhook "
                "-d \"include[]=webhook_endpoint.signing_secret\"", API_V2)
    log.warning("  the signing secret is returned once, on create, and only if you "
                "ask for it with include[]")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-v2-event-destinations.mjs
/**
 * Report a Stripe account with no v2 event destination for thin events.
 *
 * Read only. Two GETs and no writes: give this a RESTRICTED key with read access
 * to Event Destinations and Billing Meters. The repair is printed, never performed.
 */
const API_V1 = 'https://api.stripe.com/v1';
const API_V2 = 'https://api.stripe.com/v2';

// Sent explicitly. The account default may predate v2 entirely, in which case the
// v2 path errors instead of returning an empty list.
const DEFAULT_VERSION = '2025-03-31.basil';

/**
 * Classify an account's v2 event destinations. Pure, so it is testable offline.
 * The same empty list is a gap or an outage depending on `v2FeatureInUse`.
 */
export function verdict(destinations, v2FeatureInUse = false) {
  const dests = destinations ?? [];
  const thin = dests.filter((d) => d.event_payload === 'thin');
  const enabled = thin.filter((d) => d.status === 'enabled');
  if (enabled.length) {
    return ['covered', `${enabled[0].id ?? '<no id>'} is enabled and takes thin events`];
  }
  if (thin.length) {
    const d = thin[0];
    return ['disabled',
      `${d.id ?? '<no id>'} takes thin events but its status is ` +
      `${JSON.stringify(d.status)}: ${d.status_details ?? 'no status_details given'}`];
  }
  if (dests.length) {
    return ['snapshot-only',
      `${dests.length} event destination(s) exist and every one of them is ` +
      'event_payload=snapshot, which cannot carry a thin event'];
  }
  if (v2FeatureInUse) {
    return ['dropping',
      'no v2 event destination at all, and a v2 feature is in use: the thin ' +
      'events it emits are being generated and delivered nowhere'];
  }
  return ['none',
    'no v2 event destination exists. Nothing emits thin events yet, so nothing ' +
    'is being lost today.'];
}

async function get(key, url, params = {}, version = null) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const headers = { Authorization: `Bearer ${key}` };
  if (version) headers['Stripe-Version'] = version;
  const res = await fetch(u, { headers });
  if (res.status === 401) {
    throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
  }
  if ((res.status === 400 || res.status === 404) && u.pathname.startsWith('/v2/')) {
    throw new Error(`${res.status} from ${u.pathname}: this key or API version ` +
                    'cannot see v2 resources');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

/**
 * The v2 list endpoints paginate with an absolute next_page_url rather than the
 * starting_after cursor the v1 list endpoints use.
 */
export async function eventDestinations(key, version = DEFAULT_VERSION) {
  const out = [];
  let url = `${API_V2}/core/event_destinations`;
  let params = { limit: 100 };
  while (url) {
    const page = await get(key, url, params, version);
    out.push(...(page.data ?? []));
    url = page.next_page_url ?? null;
    params = {};
  }
  return out;
}

export async function v2FeatureInUse(key) {
  const page = await get(key, `${API_V1}/billing/meters`, { limit: 1 });
  return (page.data ?? []).length > 0;
}

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 version = process.argv[2] ?? DEFAULT_VERSION;
  const dests = await eventDestinations(key, version);
  const inUse = await v2FeatureInUse(key);
  const [state, detail] = verdict(dests, inUse);

  console.log(`${state.padEnd(16)} ${detail}`);
  for (const d of dests) {
    console.log(`  ${d.id}  payload=${d.event_payload}  status=${d.status}  ` +
                `events_from=${JSON.stringify(d.events_from ?? null)}`);
  }
  if (state === 'covered') return;

  if (state === 'disabled') {
    console.warn('  repair: fix the handler, then re-enable the destination at ' +
                 `${API_V2}/core/event_destinations/<id>/enable`);
    process.exitCode = 1;
    return;
  }

  console.warn('  repair: create a thin destination (a separate object from any ' +
               '/v1/webhook_endpoints you already have):');
  console.warn(`  POST ${API_V2}/core/event_destinations -d type=webhook_endpoint ` +
               '-d event_payload=thin -d "events_from[]=@self" ' +
               '-d "enabled_events[]=v1.billing.meter.error_report_triggered" ' +
               '-d webhook_endpoint[url]=https://<yourdomain>/stripe/thin-webhook ' +
               '-d "include[]=webhook_endpoint.signing_secret"');
  console.warn('  the signing secret is returned once, on create, and only if you ' +
               'ask for it with include[]');
  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 test to read is the snapshot-only one. An account with three v2 event destinations, none of them thin, satisfies every check that counts rows and drops every thin event Stripe generates. The other one worth pinning is the split between an empty list on an account using Billing meters and an empty list on an account that is not: identical configuration, and only one of them is currently losing events.

test_stripe_v2_event_destinations.py
from stripe_v2_event_destinations import verdict

THIN = {"id": "ed_1", "event_payload": "thin", "status": "enabled"}
SNAPSHOT = {"id": "ed_2", "event_payload": "snapshot", "status": "enabled"}


def test_an_enabled_thin_destination_is_all_that_is_needed():
    state, detail = verdict([THIN, SNAPSHOT], True)
    assert state == "covered"
    assert "ed_1" in detail


def test_a_thin_destination_that_is_disabled_delivers_nothing():
    dead = {"id": "ed_3", "event_payload": "thin", "status": "disabled",
            "status_details": "disabled after repeated 500s"}
    state, detail = verdict([dead], True)
    assert state == "disabled"
    assert "repeated 500s" in detail


def test_snapshot_destinations_do_not_count_as_coverage():
    # Three destinations, a row count that looks configured, and not one of them
    # can carry a thin event.
    state, detail = verdict([SNAPSHOT, SNAPSHOT, SNAPSHOT], True)
    assert state == "snapshot-only"
    assert "3 event destination(s)" in detail


def test_nothing_configured_while_a_v2_feature_runs_is_an_outage():
    state, detail = verdict([], True)
    assert state == "dropping"
    assert "delivered nowhere" in detail


def test_nothing_configured_and_no_v2_feature_is_only_a_gap():
    assert verdict([], False)[0] == "none"
    assert verdict(None, False)[0] == "none"
stripe-v2-event-destinations.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-v2-event-destinations.mjs';

const THIN = { id: 'ed_1', event_payload: 'thin', status: 'enabled' };
const SNAPSHOT = { id: 'ed_2', event_payload: 'snapshot', status: 'enabled' };

test('an enabled thin destination is all that is needed', () => {
  const [state, detail] = verdict([THIN, SNAPSHOT], true);
  assert.equal(state, 'covered');
  assert.match(detail, /ed_1/);
});

test('a thin destination that is disabled delivers nothing', () => {
  const dead = { id: 'ed_3', event_payload: 'thin', status: 'disabled',
                 status_details: 'disabled after repeated 500s' };
  const [state, detail] = verdict([dead], true);
  assert.equal(state, 'disabled');
  assert.match(detail, /repeated 500s/);
});

test('snapshot destinations do not count as coverage', () => {
  const [state, detail] = verdict([SNAPSHOT, SNAPSHOT, SNAPSHOT], true);
  assert.equal(state, 'snapshot-only');
  assert.match(detail, /3 event destination/);
});

test('nothing configured while a v2 feature runs is an outage', () => {
  const [state, detail] = verdict([], true);
  assert.equal(state, 'dropping');
  assert.match(detail, /delivered nowhere/);
});

test('nothing configured and no v2 feature is only a gap', () => {
  assert.equal(verdict([], false)[0], 'none');
  assert.equal(verdict(null, false)[0], 'none');
});

FAQ

Why is the event called v1.something if it needs a v2 destination?

The prefix names the version of the resource the event describes, not the API that delivers it. v1.billing.meter.error_report_triggered is an event about a v1-shaped billing meter, delivered as a thin event through the v2 event destination system. Reading the prefix as a delivery version is the exact mistake that puts it on the wrong endpoint.

Can I add a thin event type to my existing /v1/webhook_endpoints endpoint?

No. A snapshot endpoint carries snapshot events. Listing a thin event type there does not cause delivery, and the absence of an error at save time is what makes the failure so quiet.

Does a v2 destination replace my v1 webhook endpoint?

Not unless you want it to. Most accounts end up with both: the v1 endpoint keeps taking snapshot events with full payloads, and the v2 thin destination takes the newer ones. They are separate objects with separate signing secrets, and both have to be verified separately.

Where do I get the signing secret for a v2 event destination?

It is returned on create, and only if you ask for it: pass include[]=webhook_endpoint.signing_secret in the create call. It is not shown again afterwards, so capture it in that response rather than planning to look it up later.

Why does the thin event payload not contain the object?

By design. A thin event carries the id, the type and a pointer, and you fetch the related object yourself at your own API version. That removes the pinned-version problem snapshot events have, and it means a handler copied from the v1 side that reads event.data.object will find nothing there.

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.