Skip to content

Diagnostic Stripe

payment link hit its completed-session limit and went dead

The campaign link worked. Fifty people bought through it and then the orders stopped, on a link nobody touched, in a week nobody deployed. The link is still active, still resolving, still in the email that goes out every morning. It reached the number of completed sessions it was created with and closed itself.

Read-only key Python and Node.js Tests included
Holding an envelope
Photo by erica steeves on Unsplash
The short answer

Page GET /v1/payment_links?limit=100 and read restrictions.completed_sessions. The failure is count >= limit: the cap has been met and the link no longer accepts completions. count / limit >= 0.9 is the same link a few days earlier, which is the version worth catching.

Note that active is still true on an exhausted link. This is not the deactivated-link case in A deactivated Payment Link is still linked from your site — nobody switched this one off. It reached a number.

The problem in plain words

A completion cap is set once, usually for a good reason: a limited run, a workshop with twenty seats, a founding-member price for the first hundred customers. It is set at creation, in a nested parameter, and then it is never seen again, because the link object is not something anyone opens after the campaign starts.

The counter that eventually closes the link is read-only and silent. There is no event when it approaches the limit and no event when it reaches it. From the outside the link looks completely normal: the URL resolves, active is true, and Stripe simply stops letting new sessions complete on it. Meanwhile every place the URL is published carries on sending people to it.

Cap set atcreationa limited runCampaignpublishedURL pastedeverywhereCounter reachescapno notificationCompletionsrefusedlink still activeBlamed ondemandcampaign ran itscourse
The link stays active and the URL keeps resolving, so every check anyone owns still reports it healthy.

Why it happens

The cap and the traffic are set by different people at different times. Whoever created the link picked a number that matched the plan that week. Whoever scheduled the newsletter, printed the QR code or wired the link into the pricing page did so later and knows nothing about a restriction nested three keys deep in the object.

There is no notification as the counter approaches the cap. restrictions.completed_sessions.count is maintained by Stripe and cannot be written. Nothing fires at 90%, nothing fires at 100%, and the only way to see it is to ask for the object.

The symptom looks like demand, not like a bug. Sales tail off at the end of a campaign, which is what campaigns do. A link that stops converting after fifty orders looks exactly like a campaign that ran out of audience, and that is the story everyone will believe until somebody reads the counter.

The link stays active, so every check you own passes. A link checker gets a 200. A script that looks for active == false finds nothing. The one field that changed is a counter inside restrictions, and no generic health check has ever looked there.

The fix, as a flow

The script reads the restrictions object on every link, then counts the sessions a capped link is still creating, because an exhausted link that nobody clicks is housekeeping and one that people click is lost revenue.

GET /v1/payment_linksplus sessions per capped linkNo restrictionsuncappedWell inside the capheadroomCounter missingunread, not zeroOver 90 percent usedraise it this weekCap met, still clickedcustomers turned away
The near limit state is the only one where this is still cheap: the cap can be raised before anyone is turned away.

How to fix it

List every Payment Link and read the restrictions object

Paginate GET /v1/payment_links?limit=100. restrictions is null on most links, which is the uncapped case and the one you can stop thinking about. The links with a completed_sessions.limit are the whole population of this problem.

Compare count against limit, and flag the near misses too

count >= limit is already dead. count / limit >= 0.9 is dead this week. The second one is the finding worth acting on, because it is the only version of this you can fix before customers are turned away.

Check whether people are still arriving

GET /v1/checkout/sessions?payment_link={plink_id}&limit=100. Recent sessions sitting at open or expired against an exhausted link are customers who clicked, found they could not complete, and left. That count is what turns this from housekeeping into lost revenue.

Decide whether the cap was a business rule or a guess

Twenty seats in a room is a real constraint and the link doing its job. "A hundred, to be safe" is a guess that has now become a revenue ceiling. Only one of those should be raised, and the difference is not something the API can tell you.

Raise the cap or publish a fresh link

POST /v1/payment_links/{plink_id} with restrictions[completed_sessions][limit] set higher keeps the same URL, which fixes every place it was published at once. A new link for the next tranche means swapping the URL everywhere, which is the slower option and sometimes the right one.

How to check it worked

Re-run the script. Every capped link should have headroom, and no link should be reporting recent sessions it cannot complete.

python3 stripe_payment_link_limits.py
# headroom     plink_1MoBy5  42 of 200 completed session(s)
# uncapped     plink_1KqAa2  no completion limit set

The full code

Two GETs and no writes — a restricted key with read access to Payment Links and Checkout Sessions is enough. The classifier is pure and takes the restrictions object exactly as Stripe returns it, plus the recent session count, because the interesting judgement here is the difference between a link that is exhausted and one that is exhausted and still being clicked.

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_link_limits.py
"""Report Stripe Payment Links that have reached their completed-session limit.

Read only. Two GETs and no writes: give this a RESTRICTED key with read access to
Payment Links and Checkout Sessions. 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 time

import requests

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

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

NEAR = 0.9  # far enough along the cap that it will be met before anyone looks again


def verdict(restrictions, recent_sessions=0):
    """Classify one Payment Link's completion cap. Pure, so it is testable offline.

    `restrictions` is the link's restrictions object, which is None on most links.
    `recent_sessions` is the number of Checkout Sessions it created in the window,
    which separates a dead link nobody visits from one that is turning people away.
    Returns (state, detail).
    """
    completed = ((restrictions or {}).get("completed_sessions") or {})
    limit = completed.get("limit")
    count = completed.get("count")
    if limit is None:
        return ("uncapped", "no completion limit set")
    if count is None:
        return ("unknown",
                "capped at %s and the counter is missing from the response; treat "
                "it as unread rather than as zero" % limit)
    if count >= limit:
        if recent_sessions:
            return ("exhausted-in-use",
                    "%d of %d completed session(s): the cap is met and %d "
                    "customer(s) have still arrived since"
                    % (count, limit, recent_sessions))
        return ("exhausted",
                "%d of %d completed session(s): the cap is met and the link no "
                "longer accepts completions" % (count, limit))
    if limit and count / float(limit) >= NEAR:
        return ("near-limit",
                "%d of %d completed session(s): this link closes itself within "
                "days at the current rate" % (count, limit))
    return ("headroom", "%d of %d completed session(s)" % (count, limit))


def get(session, path, params=None):
    r = session.get(API + path, params=params or {}, 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 payment_links(session, cap):
    """Every Payment Link on the account, capped and not."""
    out = []
    params = {"limit": 100}
    while True:
        page = get(session, "/payment_links", params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= cap:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def recent_session_count(session, link_id, since):
    """Sessions this link created since `since`, whatever their status.

    An exhausted link still creates sessions; they just cannot complete. Counting
    them is how you find out whether the published URL is still in circulation.
    """
    count = 0
    params = {"payment_link": link_id, "limit": 100}
    while True:
        page = get(session, "/checkout/sessions", params)
        data = page.get("data", [])
        for cs in data:
            if (cs.get("created") or 0) >= since:
                count += 1
        if not data or not page.get("has_more"):
            break
        if (data[-1].get("created") or 0) < since:
            break
        params["starting_after"] = data[-1]["id"]
    return count


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="how far back a session still counts as recent traffic")
    ap.add_argument("--max-links", type=int, default=500,
                    help="stop paginating after this many Payment Links")
    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})

    since = int(time.time()) - args.days * 86400
    bad = 0
    for link in payment_links(s, args.max_links):
        restrictions = link.get("restrictions")
        # Only capped links are worth a second request, and most links are not.
        recent = 0
        if restrictions:
            recent = recent_session_count(s, link["id"], since)
        state, detail = verdict(restrictions, recent)
        line = "%-17s %-20s %s" % (state, link["id"], detail)
        if state in ("uncapped", "headroom"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  published at: %s", link.get("url") or "<no url>")
        log.warning("  repair: raise the cap and keep the same URL:")
        log.warning("  POST %s/payment_links/%s -d "
                    "\"restrictions[completed_sessions][limit]=<higher>\"",
                    API, link["id"])
        log.warning("  or create a fresh link for the next tranche and swap the "
                    "published URL everywhere it appears")

    log.info("%d capped link(s) at or near their completion limit", bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-payment-link-limits.mjs
/**
 * Report Stripe Payment Links that have reached their completed-session limit.
 *
 * Read only. Two GETs and no writes: give this a RESTRICTED key with read access
 * to Payment Links and Checkout Sessions. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

const NEAR = 0.9; // far enough along the cap that it closes before anyone looks again

/**
 * Classify one Payment Link's completion cap. Pure, so it is testable offline.
 * A missing counter is unread, not zero.
 */
export function verdict(restrictions, recentSessions = 0) {
  const completed = (restrictions ?? {}).completed_sessions ?? {};
  const limit = completed.limit;
  const count = completed.count;
  if (limit === null || limit === undefined) {
    return ['uncapped', 'no completion limit set'];
  }
  if (count === null || count === undefined) {
    return ['unknown',
      `capped at ${limit} and the counter is missing from the response; treat it ` +
      'as unread rather than as zero'];
  }
  if (count >= limit) {
    if (recentSessions) {
      return ['exhausted-in-use',
        `${count} of ${limit} completed session(s): the cap is met and ` +
        `${recentSessions} customer(s) have still arrived since`];
    }
    return ['exhausted',
      `${count} of ${limit} completed session(s): the cap is met and the link no ` +
      'longer accepts completions'];
  }
  if (limit && count / limit >= NEAR) {
    return ['near-limit',
      `${count} of ${limit} completed session(s): this link closes itself within ` +
      'days at the current rate'];
  }
  return ['headroom', `${count} of ${limit} completed session(s)`];
}

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();
}

export async function paymentLinks(key, cap = 500) {
  const out = [];
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/payment_links', params);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= cap) break;
    params.starting_after = data[data.length - 1].id;
  }
  return out;
}

export async function recentSessionCount(key, linkId, since) {
  let count = 0;
  const params = { payment_link: linkId, limit: 100 };
  for (;;) {
    const page = await get(key, '/checkout/sessions', params);
    const data = page.data ?? [];
    for (const cs of data) if ((cs.created ?? 0) >= since) count += 1;
    if (data.length === 0 || !page.has_more) break;
    if ((data[data.length - 1].created ?? 0) < since) break;
    params.starting_after = data[data.length - 1].id;
  }
  return count;
}

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 days = Number(process.argv[2] ?? 30);
  const since = Math.floor(Date.now() / 1000) - days * 86400;
  let bad = 0;
  for (const link of await paymentLinks(key)) {
    const restrictions = link.restrictions;
    const recent = restrictions ? await recentSessionCount(key, link.id, since) : 0;
    const [state, detail] = verdict(restrictions, recent);
    const line = `${state.padEnd(17)} ${link.id.padEnd(20)} ${detail}`;
    if (state === 'uncapped' || state === 'headroom') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  published at: ${link.url ?? '<no url>'}`);
    console.warn('  repair: raise the cap and keep the same URL:');
    console.warn(`  POST ${API}/payment_links/${link.id} -d ` +
                 '"restrictions[completed_sessions][limit]=<higher>"');
    console.warn('  or create a fresh link for the next tranche and swap the ' +
                 'published URL everywhere it appears');
  }

  console.log(`${bad} capped link(s) at or near their completion limit`);
  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 test that earns its place is the missing counter. restrictions.completed_sessions.count absent is not the same as zero, and a classifier that treats it as zero reports a link with full headroom on the one link it could not actually read. The near-limit test is the other one: it is the only state where this problem is still cheap to fix.

test_stripe_payment_link_limits.py
from stripe_payment_link_limits import verdict


def test_a_link_with_no_restrictions_is_uncapped():
    assert verdict(None)[0] == "uncapped"
    assert verdict({})[0] == "uncapped"


def test_a_link_well_inside_its_cap_has_headroom():
    state, detail = verdict({"completed_sessions": {"limit": 200, "count": 42}})
    assert state == "headroom"
    assert "42 of 200" in detail


def test_a_link_at_ninety_percent_is_the_one_worth_catching():
    state, detail = verdict({"completed_sessions": {"limit": 100, "count": 92}})
    assert state == "near-limit"
    assert "closes itself" in detail


def test_an_exhausted_link_with_no_traffic_is_only_housekeeping():
    assert verdict({"completed_sessions": {"limit": 50, "count": 50}})[0] == "exhausted"


def test_an_exhausted_link_still_being_clicked_is_lost_revenue():
    state, detail = verdict({"completed_sessions": {"limit": 50, "count": 50}}, 18)
    assert state == "exhausted-in-use"
    assert "18 customer(s)" in detail


def test_a_missing_counter_is_not_read_as_zero():
    # Reading absent as zero would report full headroom on the one link the
    # response did not describe.
    assert verdict({"completed_sessions": {"limit": 50}})[0] == "unknown"
stripe-payment-link-limits.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-payment-link-limits.mjs';

test('a link with no restrictions is uncapped', () => {
  assert.equal(verdict(null)[0], 'uncapped');
  assert.equal(verdict({})[0], 'uncapped');
});

test('a link well inside its cap has headroom', () => {
  const [state, detail] = verdict({ completed_sessions: { limit: 200, count: 42 } });
  assert.equal(state, 'headroom');
  assert.match(detail, /42 of 200/);
});

test('a link at ninety percent is the one worth catching', () => {
  const [state, detail] = verdict({ completed_sessions: { limit: 100, count: 92 } });
  assert.equal(state, 'near-limit');
  assert.match(detail, /closes itself/);
});

test('an exhausted link with no traffic is only housekeeping', () => {
  const r = { completed_sessions: { limit: 50, count: 50 } };
  assert.equal(verdict(r)[0], 'exhausted');
});

test('an exhausted link still being clicked is lost revenue', () => {
  const r = { completed_sessions: { limit: 50, count: 50 } };
  const [state, detail] = verdict(r, 18);
  assert.equal(state, 'exhausted-in-use');
  assert.match(detail, /18 customer/);
});

test('a missing counter is not read as zero', () => {
  assert.equal(verdict({ completed_sessions: { limit: 50 } })[0], 'unknown');
});

FAQ

What does a customer see on a link that has met its limit?

A Stripe-hosted page saying the link is no longer available, not an error. The URL still resolves with a 200, so uptime checks and link checkers report it as healthy, exactly as they do for a deactivated link.

Is this the same as deactivating the link?

No, and the fields differ. A deactivated link has active false because somebody switched it off. An exhausted link is still active true; what changed is restrictions.completed_sessions.count reaching the limit. A script that only looks at active will miss this entirely.

Can I reset the completed-sessions counter?

No. The counter is read-only and maintained by Stripe. You can raise the limit above the current count, which reopens the link with its existing URL, or create a new link if you want the count to start again from zero.

Does the limit count sessions created or sessions completed?

Completed ones. Sessions that are opened and abandoned do not consume the cap, which is why a link can show far more traffic than its counter and why the session count and the counter are two different numbers worth reading side by side.

Will Stripe warn me before the limit is reached?

No. There is no event and no notification as the counter approaches the cap or when it meets it. Polling the object is the only way to see it coming, which is what makes the ninety-percent check the useful half of this script.

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.