Skip to content

Diagnostic Stripe

a deactivated Payment Link is still linked from your site

Somebody deactivated a Payment Link in the Dashboard six weeks ago, for a good reason. The URL is still in a landing page, a scheduled email and a PDF invoice template, and every customer who clicks it lands on a Stripe page explaining that the link is no longer active. Your server never hears about any of it.

Read-only key Python and Node.js Tests included
An image of a cell phone with a target in it
Photo by Growtika on Unsplash
The short answer

Page GET /v1/payment_links?limit=100 and collect every url where active == false. Diff that set against the URLs your site, emails and documents actually reference. Anything in both lists is a buy button that goes nowhere.

To sort the urgent ones from the merely untidy, ask whether the dead link is still receiving traffic: GET /v1/checkout/sessions?payment_link={plink_id}&limit=100 and look at recent created timestamps. A dead link with sessions last week is losing sales now. A dead link nobody has touched in a year is housekeeping.

The problem in plain words

The failure is entirely one-sided. Stripe knows the link is inactive; your site does not, and there is no mechanism by which it ever could. The url keeps resolving forever — that is deliberate, so that old links do not become 404s — and what changes is only what Stripe serves at the other end. From your side nothing happened. No request hit your servers, no error was logged, no exception was raised. The customer simply stopped.

What makes this expensive rather than annoying is where Payment Link URLs end up. They are designed to be pasted: into a landing page, a newsletter, an invoice PDF, a QR code on a printed flyer, a social bio. Deactivating the link takes one click in the Dashboard and no part of that click reaches any of those places. Six weeks later somebody notices that one product has sold nothing since March.

Linkdeactivatedone click, goodreasonURL stillpublishedpage, email,invoice PDFCustomer clicksURL returns 200Deactivationpagesale ends thereNothing loggedfound six weekslater
The URL keeps resolving forever. What changed is only what Stripe serves at the other end, and no request ever reaches your server.

Why it happens

Deactivation is reversible, so it feels safe. active is a flag you can flip back, which is precisely why people flip it in the first place: to pause a product, to stop a promotion, to take something down while a price is corrected. The intent is temporary. The published URL does not know that, and the intent to re-enable it is exactly the kind of thing that gets forgotten.

The link keeps working, for a definition of working. Requesting the URL returns 200 with a real Stripe page. Every uptime check, link checker and crawler you own will report it as fine, because by every mechanical measure it is fine. The only thing wrong is the content, and no automated check you have is reading it.

There is no reverse index from a link to where you published it. Stripe can tell you a link is inactive. It has no idea that the URL is in a MailChimp template and on page four of a PDF. Only you can hold that list, and almost nobody does, so the diff that would catch this has to be run deliberately.

inactive_message is the clue that it was on purpose. When somebody sets a custom message shown to customers who hit a deactivated link, they knew people were still arriving. That is a link that was consciously retired and consciously left published — and worth checking that the message actually forwards people somewhere useful, rather than telling them nothing.

The fix, as a flow

The script lists every Payment Link and then asks each dead one whether customers are still arriving, because that count is what separates a lost sale from a tidy-up.

GET /v1/payment_linksplus sessions per linkactive truelive, leave itInactive, no traffichousekeepingInactive with trafficsales lost right nowNo active flagunknown, check by hand
A missing active flag is a thing you do not know, not a thing you know is false. Absent is not the same as deactivated.

How to fix it

List every Payment Link, not just the inactive ones

Paginate GET /v1/payment_links?limit=100. You can pass active=false to isolate the dead ones, but the full list is more useful: it is also the inventory you will diff your site against, and it tells you which live link a dead product should be repointed at.

Check each dead link for recent sessions

GET /v1/checkout/sessions?payment_link={plink_id}&limit=100 returns the sessions that link created. Recent timestamps on an inactive link mean customers are still arriving and being turned away. This is the number that turns the finding into a priority.

Grep your own content for the URLs

The Stripe half of the diff is easy; the other half is your repository, your CMS export and your email templates. Search for buy.stripe.com across all of them. Whatever the script cannot see is where the dead links actually live.

Decide per link: republish or reactivate

If the product is still sold, point the published URL at a live link. If the link was paused by mistake, POST /v1/payment_links/{plink_id} -d active=true brings it straight back with the same URL, which is the one repair that fixes every publication at once.

Set inactive_message on anything you leave dead

For links you intend to retire, give the deactivation page something useful to say — where the product moved to, or who to contact. It does not recover the sale automatically, but it converts a dead end into a redirect a human can follow.

How to check it worked

Re-run the script. Every inactive link should either be reactivated or reporting no recent traffic.

python3 stripe_inactive_payment_links.py
# live        plink_1MoBy5  https://buy.stripe.com/...  12 session(s) in the window
# dormant     plink_1KqAa2  inactive and nothing has reached it in 90 days

The full code

Two GETs and no writes — a restricted key with read access to Payment Links and Checkout Sessions is enough. The classification is pure and takes the active flag, the recent session count and inactive_message, so the difference between a link that is quietly losing sales and one that is merely stale is decided by visible rules rather than inside a request loop.

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_inactive_payment_links.py
"""Report Stripe Payment Links that are deactivated but still receiving traffic.

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

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


def verdict(active, recent_sessions, inactive_message=None):
    """Classify one Payment Link. Pure, so the rules can be tested without a network.

    `active` is the link's flag, `recent_sessions` the number of Checkout Sessions
    it created inside the window, and `inactive_message` the custom text shown on
    the deactivation page. Returns (state, detail).
    """
    if active is None:
        return ("unknown",
                "the link has no active flag; treat it as published until you know "
                "otherwise (%d recent session(s))" % recent_sessions)
    if active:
        return ("live", "%d session(s) in the window" % recent_sessions)
    if recent_sessions:
        if inactive_message:
            return ("dead-signposted",
                    "inactive, %d recent session(s), and customers at least see: %r"
                    % (recent_sessions, inactive_message))
        return ("dead-in-use",
                "inactive but still reached %d time(s) in the window: every one of "
                "those landed on Stripe's deactivation page" % recent_sessions)
    return ("dormant", "inactive and nothing has reached it in the window")


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, active 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`.

    Only the count matters, but the pagination still has to happen: a busy link
    can fill a page with sessions from a single afternoon.
    """
    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=90,
                    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):
        count = recent_session_count(s, link["id"], since)
        state, detail = verdict(link.get("active"), count, link.get("inactive_message"))
        line = "%-15s %-20s %s" % (state, link["id"], detail)
        if state in ("live", "dormant"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  published at: %s", link.get("url") or "<no url>")
        log.warning("  repair: repoint the published URL at a live link, or bring "
                    "this one back:")
        log.warning("  POST %s/payment_links/%s -d active=true", API, link["id"])
        if not link.get("inactive_message"):
            log.warning("  if it stays dead, give the deactivation page a "
                        "forwarding instruction with -d inactive_message=\"...\"")

    log.info("%d inactive link(s) still taking traffic", bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-inactive-payment-links.mjs
/**
 * Report Stripe Payment Links that are deactivated but still receiving traffic.
 *
 * 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';

/**
 * Classify one Payment Link. Pure, so the rules can be tested without a network.
 * `active` may be null, which is not the same as false.
 */
export function verdict(active, recentSessions, inactiveMessage = null) {
  if (active === null || active === undefined) {
    return ['unknown',
      'the link has no active flag; treat it as published until you know ' +
      `otherwise (${recentSessions} recent session(s))`];
  }
  if (active) return ['live', `${recentSessions} session(s) in the window`];
  if (recentSessions) {
    if (inactiveMessage) {
      return ['dead-signposted',
        `inactive, ${recentSessions} recent session(s), and customers at least ` +
        `see: '${inactiveMessage}'`];
    }
    return ['dead-in-use',
      `inactive but still reached ${recentSessions} time(s) in the window: every ` +
      "one of those landed on Stripe's deactivation page"];
  }
  return ['dormant', 'inactive and nothing has reached it in the window'];
}

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] ?? 90);
  const since = Math.floor(Date.now() / 1000) - days * 86400;
  let bad = 0;
  for (const link of await paymentLinks(key)) {
    const count = await recentSessionCount(key, link.id, since);
    const [state, detail] = verdict(link.active, count, link.inactive_message);
    const line = `${state.padEnd(15)} ${link.id.padEnd(20)} ${detail}`;
    if (state === 'live' || state === 'dormant') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  published at: ${link.url ?? '<no url>'}`);
    console.warn('  repair: repoint the published URL at a live link, or bring ' +
                 'this one back:');
    console.warn(`  POST ${API}/payment_links/${link.id} -d active=true`);
    if (!link.inactive_message) {
      console.warn('  if it stays dead, give the deactivation page a forwarding ' +
                   'instruction with -d inactive_message="..."');
    }
  }

  console.log(`${bad} inactive link(s) still taking traffic`);
  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 last test is the one that matters most and looks the most pedantic. active is a boolean, but a truncated response, an expanded field or a stubbed fixture can hand you None instead, and a plain if not active would then report every link on the account as dead. A missing flag is a thing you do not know, not a thing you know is false.

test_stripe_inactive_payment_links.py
from stripe_inactive_payment_links import verdict


def test_an_active_link_is_live():
    assert verdict(True, 12)[0] == "live"


def test_a_dead_link_with_recent_traffic_is_the_expensive_case():
    state, detail = verdict(False, 9)
    assert state == "dead-in-use"
    assert "9 time(s)" in detail


def test_an_inactive_message_softens_it_but_does_not_clear_it():
    state, detail = verdict(False, 9, "We moved to the new plan page")
    assert state == "dead-signposted"
    assert "new plan page" in detail


def test_a_dead_link_nobody_visits_is_only_housekeeping():
    assert verdict(False, 0)[0] == "dormant"


def test_a_missing_active_flag_is_not_read_as_deactivated():
    # `if not active` would call this dead and print a repair for a link that is
    # working perfectly. Absent is not false.
    assert verdict(None, 3)[0] == "unknown"
stripe-inactive-payment-links.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-inactive-payment-links.mjs';

test('an active link is live', () => {
  assert.equal(verdict(true, 12)[0], 'live');
});

test('a dead link with recent traffic is the expensive case', () => {
  const [state, detail] = verdict(false, 9);
  assert.equal(state, 'dead-in-use');
  assert.match(detail, /9 time\(s\)/);
});

test('an inactive message softens it but does not clear it', () => {
  const [state, detail] = verdict(false, 9, 'We moved to the new plan page');
  assert.equal(state, 'dead-signposted');
  assert.match(detail, /new plan page/);
});

test('a dead link nobody visits is only housekeeping', () => {
  assert.equal(verdict(false, 0)[0], 'dormant');
});

test('a missing active flag is not read as deactivated', () => {
  assert.equal(verdict(null, 3)[0], 'unknown');
  assert.equal(verdict(undefined, 3)[0], 'unknown');
});

FAQ

What does a customer see when they open a deactivated Payment Link?

A Stripe-hosted page telling them the link is no longer active. It returns a normal 200 response, so every link checker and uptime monitor you own will call it healthy. If inactive_message is set, your text is shown there instead of the generic wording.

Can I delete a Payment Link instead of deactivating it?

No. Payment Links cannot be deleted through the API; active is the only lever, and it can be flipped either way. That is convenient when you paused something by mistake and awkward when you wanted the URL to stop resolving, because it never will.

Does deactivating a link cancel the subscriptions it created?

No. The link is only the door. Subscriptions, invoices and payments it produced carry on exactly as before; deactivation stops new Checkout Sessions being created and nothing else.

How do I find where a link is published?

Stripe cannot tell you. Grep your repository, CMS export and email templates for buy.stripe.com, then diff that list against the URLs the script prints. Keeping a link-to-page mapping in your own system is the only durable fix, and it takes one column.

Why check for recent sessions on a link that is already dead?

Because it separates a lost sale from a tidy-up. Sessions created against an inactive link are people who found the URL, clicked it and were turned away. Zero recent sessions means the link is simply stale, and stale links can wait for the next cleanup.

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.