Skip to content

Diagnostic Stripe

Payment Link ends on Stripe's page, so fulfilment never fires

The link works. The money arrives. The customer sees a Stripe page thanking them, closes the tab, and waits for a licence key that no code was ever asked to send. Nothing failed anywhere: the flow simply ends on Stripe's side and never comes back to yours.

Read-only key Python and Node.js Tests included
Close-up of a keyboard with a prominent 'buy' button.
Photo by Money Knack on Unsplash
The short answer

Page GET /v1/payment_links?limit=100 and flag every link whose after_completion.type is hosted_confirmation — the default. Then read GET /v1/webhook_endpoints?limit=100 and ask whether any endpoint with status == "enabled" lists checkout.session.completed in enabled_events (a * entry counts).

A hosted-confirmation link on an account with no such endpoint fulfils nothing at all, and nothing anywhere reports it. The repair is both halves: after_completion[type]=redirect with a URL carrying {CHECKOUT_SESSION_ID}, and a subscription to checkout.session.completed regardless, because Stripe is explicit that you cannot rely on the landing page alone.

The problem in plain words

Payment Links exist so that you can take money without writing code, and they are extremely good at that. The trouble starts when a link created for something that needed no fulfilment — a donation, a deposit, an invoice somebody pays by hand — is copied for something that does. The link looks the same. The Dashboard looks the same. Nothing about the object says that a human now has to be sent a licence key.

Then the two halves of the repair live in different places, owned by different people. The after_completion setting is on the link, edited by whoever made it, usually in the Dashboard. The webhook endpoint is in a developer's settings, on a different screen, in a different week. Each side can look correct on its own while the account as a whole provisions nothing.

Link sharedafter_completiondefaultBuyer paysmoney arrivesStripe showsits pageflow ends thereNo redirect, nowebhooknothing is toldNothingprovisionedbuyer emailssupport
The buyer's browser never touches your domain, so no page of yours could have started the work.

Why it happens

The default is right for the original use and wrong for the new one. hosted_confirmation ends the flow on Stripe's own confirmation page. For a donation that is the correct and complete behaviour. For anything that has to be provisioned, it means the buyer's browser never touches your domain, so there is no page of yours that could have started the work.

Redirecting is necessary but not sufficient. Switching to redirect gives you a page to run something on, and that page is not guaranteed to load: the customer can close the tab, lose signal, or simply not wait. Stripe says this in its own fulfilment guide. The webhook is the only trigger that does not depend on a browser still being open.

A redirect without the placeholder is a page that knows nothing. {CHECKOUT_SESSION_ID} is substituted at redirect time. Without it the landing page can tell that somebody bought something and cannot tell what, for whom, or against which of your records.

Nothing about the link tells you whether a webhook exists. The two facts live in different objects and no view in the Dashboard joins them. That join is exactly what a script can do in two GETs, and it is the difference between a link that is merely configured oddly and an account that silently provisions nothing.

The fix, as a flow

Two GETs, joined: what each link does after payment, and whether any enabled endpoint on the account listens for the completion event. Neither fact means much without the other.

GET /v1/payment_linksplus GET /v1/webhook_endpointsRedirect and eventcoveredStripe page, event setwebhook does the workRedirect, no eventbrowser is the only triggerStripe page, no eventnothing fulfils at all
The same link is untidy on an account that subscribes the event and a silent outage on one that does not.

How to fix it

List every Payment Link and read after_completion.type

GET /v1/payment_links?limit=100, paginated. Missing or hosted_confirmation both mean the flow ends on Stripe's page; the field defaults, so a link created in the Dashboard without touching the After payment tab is in this state.

Ask the account whether the event is subscribed at all

GET /v1/webhook_endpoints?limit=100. You want an endpoint with status of enabled whose enabled_events contains checkout.session.completed or *. A disabled endpoint does not count, and neither does one in the other mode.

Check the redirect URL on the links that do redirect

A redirect to a URL with no {CHECKOUT_SESSION_ID} is a thank-you page with no handle on the purchase. It is a smaller problem than no redirect at all, and it is the one that makes people believe fulfilment is wired up when it is not.

Confirm the link is actually in use before you rank it

GET /v1/checkout/sessions?payment_link={plink_id}&limit=100 and look at the recent created timestamps and payment_status. A misconfigured link nobody has used since last year is housekeeping; one taking payments this week is an incident.

Fix the link and the endpoint, not one of them

Point after_completion at a redirect carrying the placeholder so the customer lands somewhere of yours, and subscribe checkout.session.completed so fulfilment happens whether or not they do. The redirect is the receipt; the webhook is the work.

Subscribe the async events too

If the link accepts a delayed payment method, checkout.session.completed can arrive before the money does. Add checkout.session.async_payment_succeeded and checkout.session.async_payment_failed and gate the provisioning on payment_status.

How to check it worked

Re-run after the change. Every link should be covered, meaning it redirects with the session id and the account has an enabled endpoint listening for the event.

python3 stripe_payment_link_fulfilment.py
# 7 link(s): 7 covered, 0 webhook-only, 0 landing-only, 0 blind-redirect, 0 unfulfilled

The full code

Two GETs and no writes: the links, and the account's webhook endpoints. There are two pure functions rather than one, because whether the account listens for checkout.session.completed is a fact about the whole account and the classifier has to be told it — the same link configuration is a mild untidiness on an account with a webhook and a total fulfilment failure on one without.

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_fulfilment.py
"""Report Stripe Payment Links whose completed payments fulfil nothing.

Read only. Two GETs and no writes: give this a RESTRICTED key with read access to
Payment Links and Webhook Endpoints. 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_payment_link_fulfilment")

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

COMPLETED_EVENT = "checkout.session.completed"
PLACEHOLDER = "{CHECKOUT_SESSION_ID}"


def listens_for_completion(endpoints):
    """True when some enabled endpoint would receive checkout.session.completed.

    Pure. A disabled endpoint receives nothing, and a wildcard subscription does
    receive this event even though it receives a great deal else besides.
    """
    for ep in endpoints or []:
        if ep.get("status") != "enabled":
            continue
        events = ep.get("enabled_events") or []
        if COMPLETED_EVENT in events or "*" in events:
            return True
    return False


def verdict(link, webhook_covered):
    """Classify one Payment Link. Pure, so the rules can be tested offline.

    `webhook_covered` is the account-wide fact from listens_for_completion(): the
    same link configuration means different things with and without it.
    Returns (state, detail).
    """
    after = link.get("after_completion") or {}
    kind = after.get("type") or "hosted_confirmation"

    if kind == "redirect":
        url = str((after.get("redirect") or {}).get("url") or "")
        if PLACEHOLDER not in url:
            return ("blind-redirect",
                    "redirects to %s with no %s, so the landing page cannot tell "
                    "which purchase it is confirming"
                    % (url or "an empty url", PLACEHOLDER))
        if not webhook_covered:
            return ("landing-only",
                    "the redirect is the only fulfilment trigger, and it fires "
                    "only if the customer's browser reaches your page")
        return ("covered", "redirects with the session id, and the event is subscribed")

    if kind == "hosted_confirmation":
        if webhook_covered:
            return ("webhook-only",
                    "the flow ends on Stripe's page, so fulfilment runs from "
                    "%s alone; the buyer never lands anywhere of yours"
                    % COMPLETED_EVENT)
        return ("unfulfilled",
                "the flow ends on Stripe's page and no enabled endpoint listens "
                "for %s: nothing fulfils these payments at all" % COMPLETED_EVENT)

    return ("unknown", "unrecognised after_completion.type %r" % (kind,))


def get(http, path, params=None):
    r = http.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 all_pages(http, path, limit):
    """Yield every object from a paginated list endpoint."""
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(http, path, params)
        data = page.get("data", [])
        for obj in data:
            yield obj
            seen += 1
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-links", type=int, default=1000,
                    help="stop paginating after this many payment links")
    ap.add_argument("--show", type=int, default=20,
                    help="how many failing links to print")
    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

    http = requests.Session()
    http.headers.update({"Authorization": "Bearer " + key})

    endpoints = get(http, "/webhook_endpoints", {"limit": 100}).get("data", [])
    covered = listens_for_completion(endpoints)
    if not covered:
        log.warning("no enabled webhook endpoint listens for %s in this key's mode",
                    COMPLETED_EVENT)

    tally = {"covered": 0, "webhook-only": 0, "landing-only": 0,
             "blind-redirect": 0, "unfulfilled": 0, "unknown": 0}
    examples = []
    links = 0
    for link in all_pages(http, "/payment_links", args.max_links):
        links += 1
        state, detail = verdict(link, covered)
        tally[state] = tally.get(state, 0) + 1
        if state in ("unfulfilled", "landing-only", "blind-redirect") \
                and len(examples) < args.show:
            examples.append((state, link.get("id", "?"), detail))

    log.info("%d link(s): %d covered, %d webhook-only, %d landing-only, "
             "%d blind-redirect, %d unfulfilled",
             links, tally["covered"], tally["webhook-only"], tally["landing-only"],
             tally["blind-redirect"], tally["unfulfilled"])
    for state, lid, detail in examples:
        log.warning("%-14s %s  %s", state, lid, detail)

    if tally["unfulfilled"] or tally["blind-redirect"]:
        log.warning("  repair: POST %s/payment_links/plink_XXX "
                    "-d 'after_completion[type]=redirect' "
                    "-d 'after_completion[redirect][url]="
                    "https://example.com/after-checkout?session_id=%s'",
                    API, PLACEHOLDER)
    if not covered:
        log.warning("  and subscribe an enabled endpoint to %s plus "
                    "checkout.session.async_payment_succeeded", COMPLETED_EVENT)
        log.warning("  check which links are actually in use: GET "
                    "%s/checkout/sessions?payment_link=plink_XXX", API)

    return 1 if (tally["unfulfilled"] or tally["landing-only"]
                 or tally["blind-redirect"]) else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-payment-link-fulfilment.mjs
/**
 * Report Stripe Payment Links whose completed payments fulfil nothing.
 *
 * Read only. Two GETs and no writes: give this a RESTRICTED key with read access
 * to Payment Links and Webhook Endpoints. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

export const COMPLETED_EVENT = 'checkout.session.completed';
export const PLACEHOLDER = '{CHECKOUT_SESSION_ID}';

/**
 * True when some enabled endpoint would receive checkout.session.completed.
 * Pure. A disabled endpoint receives nothing, and a wildcard subscription does
 * receive this event even though it receives a great deal else besides.
 */
export function listensForCompletion(endpoints) {
  for (const ep of endpoints ?? []) {
    if (ep.status !== 'enabled') continue;
    const events = ep.enabled_events ?? [];
    if (events.includes(COMPLETED_EVENT) || events.includes('*')) return true;
  }
  return false;
}

/**
 * Classify one Payment Link. Pure, so the rules can be tested offline.
 * `webhookCovered` is the account-wide fact from listensForCompletion().
 */
export function verdict(link, webhookCovered) {
  const after = link.after_completion ?? {};
  const kind = after.type ?? 'hosted_confirmation';

  if (kind === 'redirect') {
    const url = String(after.redirect?.url ?? '');
    if (!url.includes(PLACEHOLDER)) {
      return ['blind-redirect',
        `redirects to ${url || 'an empty url'} with no ${PLACEHOLDER}, so the ` +
        'landing page cannot tell which purchase it is confirming'];
    }
    if (!webhookCovered) {
      return ['landing-only',
        'the redirect is the only fulfilment trigger, and it fires only if the ' +
        "customer's browser reaches your page"];
    }
    return ['covered', 'redirects with the session id, and the event is subscribed'];
  }

  if (kind === 'hosted_confirmation') {
    if (webhookCovered) {
      return ['webhook-only',
        `the flow ends on Stripe's page, so fulfilment runs from ` +
        `${COMPLETED_EVENT} alone; the buyer never lands anywhere of yours`];
    }
    return ['unfulfilled',
      `the flow ends on Stripe's page and no enabled endpoint listens for ` +
      `${COMPLETED_EVENT}: nothing fulfils these payments at all`];
  }

  return ['unknown', `unrecognised after_completion.type ${JSON.stringify(kind)}`];
}

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* allPages(key, path, limit = 1000) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, path, params);
    const data = page.data ?? [];
    for (const obj of data) { yield obj; seen += 1; }
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  const covered = listensForCompletion(endpoints);
  if (!covered) {
    console.warn(`no enabled webhook endpoint listens for ${COMPLETED_EVENT} ` +
                 "in this key's mode");
  }

  const tally = { covered: 0, 'webhook-only': 0, 'landing-only': 0,
    'blind-redirect': 0, unfulfilled: 0, unknown: 0 };
  const examples = [];
  let links = 0;

  for await (const link of allPages(key, '/payment_links')) {
    links += 1;
    const [state, detail] = verdict(link, covered);
    tally[state] = (tally[state] ?? 0) + 1;
    if (['unfulfilled', 'landing-only', 'blind-redirect'].includes(state)
        && examples.length < 20) {
      examples.push([state, link.id ?? '?', detail]);
    }
  }

  console.log(`${links} link(s): ${tally.covered} covered, ` +
              `${tally['webhook-only']} webhook-only, ${tally['landing-only']} ` +
              `landing-only, ${tally['blind-redirect']} blind-redirect, ` +
              `${tally.unfulfilled} unfulfilled`);
  for (const [state, id, detail] of examples) {
    console.warn(`${state.padEnd(14)} ${id}  ${detail}`);
  }

  if (tally.unfulfilled || tally['blind-redirect']) {
    console.warn(`  repair: POST ${API}/payment_links/plink_XXX ` +
                 `-d 'after_completion[type]=redirect' ` +
                 `-d 'after_completion[redirect][url]=` +
                 `https://example.com/after-checkout?session_id=${PLACEHOLDER}'`);
  }
  if (!covered) {
    console.warn(`  and subscribe an enabled endpoint to ${COMPLETED_EVENT} plus ` +
                 'checkout.session.async_payment_succeeded');
    console.warn('  check which links are actually in use: GET ' +
                 `${API}/checkout/sessions?payment_link=plink_XXX`);
  }

  process.exitCode =
    (tally.unfulfilled || tally['landing-only'] || tally['blind-redirect']) ? 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 that matters is the same link read twice, once on an account that subscribes checkout.session.completed and once on one that does not. The link object is identical in both; only the account-wide fact changes, and it is the difference between an odd configuration and a product nobody is shipping. The wildcard endpoint gets its own test because a * subscription does cover this event, however much else it drags in.

test_stripe_payment_link_fulfilment.py
from stripe_payment_link_fulfilment import listens_for_completion, verdict

REDIRECT = {"type": "redirect",
            "redirect": {"url": "https://example.com/after"
                                "?session_id={CHECKOUT_SESSION_ID}"}}


def test_hosted_confirmation_without_a_webhook_fulfils_nothing():
    state, detail = verdict({"after_completion": {"type": "hosted_confirmation"}},
                            False)
    assert state == "unfulfilled"
    assert "nothing fulfils" in detail


def test_the_same_link_with_a_webhook_is_only_untidy():
    # Identical link object; only the account-wide fact changed.
    state, _ = verdict({"after_completion": {"type": "hosted_confirmation"}}, True)
    assert state == "webhook-only"


def test_a_missing_after_completion_is_treated_as_the_default():
    assert verdict({}, False)[0] == "unfulfilled"


def test_a_redirect_without_the_placeholder_is_blind():
    state, detail = verdict(
        {"after_completion": {"type": "redirect",
                              "redirect": {"url": "https://example.com/thanks"}}},
        True)
    assert state == "blind-redirect"
    assert "CHECKOUT_SESSION_ID" in detail


def test_a_good_redirect_still_needs_the_event_subscribed():
    assert verdict({"after_completion": REDIRECT}, True)[0] == "covered"
    assert verdict({"after_completion": REDIRECT}, False)[0] == "landing-only"


def test_only_enabled_endpoints_count_and_a_wildcard_does():
    assert listens_for_completion(
        [{"status": "enabled", "enabled_events": ["*"]}]) is True
    assert listens_for_completion(
        [{"status": "disabled",
          "enabled_events": ["checkout.session.completed"]}]) is False
    assert listens_for_completion([]) is False
stripe-payment-link-fulfilment.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { listensForCompletion, verdict } from './stripe-payment-link-fulfilment.mjs';

const REDIRECT = {
  type: 'redirect',
  redirect: { url: 'https://example.com/after?session_id={CHECKOUT_SESSION_ID}' },
};

test('hosted_confirmation without a webhook fulfils nothing', () => {
  const [state, detail] = verdict(
    { after_completion: { type: 'hosted_confirmation' } }, false);
  assert.equal(state, 'unfulfilled');
  assert.match(detail, /nothing fulfils/);
});

test('the same link with a webhook is only untidy', () => {
  const [state] = verdict(
    { after_completion: { type: 'hosted_confirmation' } }, true);
  assert.equal(state, 'webhook-only');
});

test('a missing after_completion is treated as the default', () => {
  assert.equal(verdict({}, false)[0], 'unfulfilled');
});

test('a redirect without the placeholder is blind', () => {
  const [state, detail] = verdict({ after_completion: {
    type: 'redirect', redirect: { url: 'https://example.com/thanks' } } }, true);
  assert.equal(state, 'blind-redirect');
  assert.match(detail, /CHECKOUT_SESSION_ID/);
});

test('a good redirect still needs the event subscribed', () => {
  assert.equal(verdict({ after_completion: REDIRECT }, true)[0], 'covered');
  assert.equal(verdict({ after_completion: REDIRECT }, false)[0], 'landing-only');
});

test('only enabled endpoints count and a wildcard does', () => {
  assert.equal(
    listensForCompletion([{ status: 'enabled', enabled_events: ['*'] }]), true);
  assert.equal(listensForCompletion([{ status: 'disabled',
    enabled_events: ['checkout.session.completed'] }]), false);
  assert.equal(listensForCompletion([]), false);
});

FAQ

Where is this setting in the Dashboard?

On the Payment Link, under the After payment tab: the choice between showing Stripe's confirmation page and the Don't show confirmation page option, which is the redirect. In the API it is after_completion.type, and it defaults to hosted_confirmation on every link created without touching it.

Is hosted_confirmation always wrong?

No. For a donation, a deposit or anything with nothing to provision it is exactly right, and it is also fine when a webhook does the fulfilment and the buyer never needed to land on your site. It is wrong only when the landing page was supposed to be the trigger, which is the case people assume.

Can I fulfil the order from the redirect page instead of a webhook?

Not as the only trigger. Stripe's fulfilment guide says as much: the customer can close the tab before your page loads, and the payment still succeeded. Use the redirect for the receipt the customer sees, and checkout.session.completed for the work.

What does {CHECKOUT_SESSION_ID} give me on the landing page?

The id to retrieve the Checkout Session with, which carries the line items, the customer details and whatever client_reference_id or metadata you set. Without it the page knows a purchase happened and nothing else about it.

Which events should the endpoint subscribe to?

checkout.session.completed for the ordinary case, plus checkout.session.async_payment_succeeded and checkout.session.async_payment_failed if the link accepts a delayed method such as ACH or SEPA, where completed arrives before the money does.

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.