Diagnostic Stripe
embedded Checkout never redirects and return_url is null
The form is embedded in your own page, which is the whole point of it. Then a customer pays with iDEAL: they are sent to their bank, they authenticate, and they come back to nowhere, because return_url is null and the return leg has no destination. To them the payment failed. To Stripe it did not.
Page GET /v1/checkout/sessions?created[gte]=<now-30d> and check three things. Sessions with ui_mode of embedded_page or elements and return_url of null have no return leg. Sessions with redirect_on_completion == "never" whose payment_method_types include a redirect method — ideal, bancontact, p24, sofort, eps, giropay, blik — are not offering those methods at all, because never disables them. And hosted sessions whose success_url has no {CHECKOUT_SESSION_ID} placeholder land on a page that cannot tell which session it is.
The repair is at creation: ui_mode=embedded_page with a real return_url and redirect_on_completion=if_required.
The problem in plain words
Both halves of this produce the same support ticket — "it didn't work" — and neither produces an error anywhere you are looking. Nothing throws. Your server is not in the flow. Stripe's side is behaving exactly as configured.
The volume hides it too. Card payments are unaffected, so the great majority of checkouts on most accounts are fine and the metric barely moves. What moves is a slice: the customers in the Netherlands, or Belgium, or Poland, or the subset of card holders whose issuer throws a full 3DS challenge that leaves the page. Those people either fail or vanish, and they fail consistently, which looks like a market that does not convert rather than a bug.
Why it happens
return_url and success_url are different fields for different journeys. success_url is where the hosted page sends a customer when checkout finishes. return_url is where a customer comes back to after authenticating somewhere else entirely — their bank, their wallet, a 3DS challenge page. Embedded Checkout needs the second one, and reading the first as covering it is the usual mistake.
The obvious fix for an embedded form makes it worse. "It is embedded, so it should not redirect" leads straight to redirect_on_completion=never, and never does not merely stop the completion redirect: it disables redirect-based payment methods outright. They stay listed in payment_method_types and simply are not offered, so your configuration and the customer's actual choices disagree with nothing to reconcile them.
Nobody tests the leg that leaves the browser. Test-mode card payments complete without ever leaving your page, so the return leg is never exercised in development. The first real exercise is a live customer at a real bank, and the evidence of the failure is on their screen, not in your logs.
The hosted-mode version of this is quieter still. A success_url without {CHECKOUT_SESSION_ID} works: the customer lands on a thank-you page. It just cannot say which purchase it is thanking them for, so anything that page was meant to show, or trigger, has nothing to look up.
The fix, as a flow
The script splits sessions by ui_mode first, because embedded and hosted checkouts fail in different fields, and then compares two fields that are each valid on their own and wrong together.
How to fix it
Read the last 30 days of sessions and split them by ui_mode
Embedded and hosted sessions fail differently and are checked differently. Splitting first also tells you something useful on its own: which integration is actually creating sessions, which is often not the one people describe.
Check return_url on every embedded session
A null or empty return_url means any payment method that leaves the page has nowhere to come back to. This is the whole finding for embedded integrations, and it is one field.
Compare redirect_on_completion against payment_method_types
never alongside ideal, bancontact, p24, sofort, eps, giropay or blik is a contradiction the API will not complain about. Those methods are configured and not being offered, so the loss shows up as low conversion in one region rather than as an error.
Check hosted sessions for the session id placeholder
success_url should contain the literal {CHECKOUT_SESSION_ID}. Stripe substitutes the real id at redirect time, and without it your landing page has no handle on the session it is confirming.
Fix it at creation
Pass a real return_url carrying the placeholder, and use redirect_on_completion=if_required so the redirect happens only when the payment method demands it. That keeps the embedded experience for cards and keeps the redirect methods working.
Do not fulfil from the page the customer lands on
The return page tells the customer something happened; it is not a reliable trigger, because the customer can close the tab before it loads. Fulfilment belongs on checkout.session.completed, which fires with or without a browser.
How to check it worked
Re-run after the change. Embedded sessions should report ok, and no session should report blocked.
python3 stripe_checkout_return_urls.py --days 7
# 340 session(s): 340 ok, 0 stranded, 0 blocked, 0 unjoinable
The full code
One paginated GET against /v1/checkout/sessions, no writes. The classifier is pure and holds the redirect-method list as data, because the interesting finding — a session configured with iDEAL that will never show iDEAL — is a comparison between two fields that are individually valid and only wrong together.
"""Report Stripe Checkout Sessions whose return leg has no destination.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to 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_checkout_return_urls")
API = "https://api.stripe.com/v1"
# Methods that take the customer off your page to authenticate. redirect_on_completion
# of "never" disables these outright rather than merely skipping a redirect.
REDIRECT_METHODS = ("ideal", "bancontact", "p24", "sofort", "eps", "giropay", "blik")
# Stripe has spelled the ui_mode values differently across API versions, so accept
# both rather than reporting a pinned older version as unknown.
EMBEDDED_MODES = ("embedded_page", "embedded", "elements")
HOSTED_MODES = ("hosted_page", "hosted")
PLACEHOLDER = "{CHECKOUT_SESSION_ID}"
def verdict(session):
"""Classify one Checkout Session. Pure, so the rules can be tested offline.
Returns (state, detail).
"""
ui = session.get("ui_mode") or HOSTED_MODES[0]
methods = [m for m in (session.get("payment_method_types") or [])
if m in REDIRECT_METHODS]
if ui in EMBEDDED_MODES:
if session.get("redirect_on_completion") == "never" and methods:
return ("blocked",
"redirect_on_completion=never disables redirect-based methods, "
"so %s are configured but never offered" % ", ".join(methods))
if not str(session.get("return_url") or "").strip():
return ("stranded",
"ui_mode=%s with no return_url: a customer who authenticates "
"off-site comes back to nowhere" % (ui,))
return ("ok", "ui_mode=%s with a return_url" % (ui,))
if ui in HOSTED_MODES:
success = str(session.get("success_url") or "")
if PLACEHOLDER not in success:
return ("unjoinable",
"success_url is %s: no %s placeholder, so the landing page "
"cannot tell which session it is confirming"
% (success or "empty", PLACEHOLDER))
return ("ok", "hosted, and success_url carries the session id")
return ("unknown", "unrecognised ui_mode %r" % (ui,))
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 sessions(http, since, limit):
"""Yield Checkout Sessions created since `since`, newest first."""
seen = 0
params = {"limit": 100, "created[gte]": int(since)}
while True:
page = get(http, "/checkout/sessions", params)
data = page.get("data", [])
for s in data:
yield s
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("--days", type=int, default=30,
help="how far back to read sessions")
ap.add_argument("--max-sessions", type=int, default=5000,
help="stop paginating after this many sessions")
ap.add_argument("--show", type=int, default=10,
help="how many failing session ids 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})
tally = {"ok": 0, "stranded": 0, "blocked": 0, "unjoinable": 0, "unknown": 0}
examples = []
total = 0
for s in sessions(http, time.time() - args.days * 86400, args.max_sessions):
total += 1
state, detail = verdict(s)
tally[state] = tally.get(state, 0) + 1
if state != "ok" and len(examples) < args.show:
examples.append((state, s.get("id", "?"), detail))
log.info("%d session(s): %d ok, %d stranded, %d blocked, %d unjoinable",
total, tally["ok"], tally["stranded"], tally["blocked"],
tally["unjoinable"])
for state, sid, detail in examples:
log.warning("%-10s %s %s", state, sid, detail)
if tally["stranded"] or tally["blocked"]:
log.warning(" repair: POST %s/checkout/sessions -d ui_mode=embedded_page "
"-d return_url='https://example.com/after-checkout"
"?session_id=%s' -d redirect_on_completion=if_required",
API, PLACEHOLDER)
if tally["unjoinable"]:
log.warning(" repair: POST %s/checkout/sessions "
"-d success_url='https://example.com/thanks?session_id=%s'",
API, PLACEHOLDER)
return 1 if total - tally["ok"] else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe Checkout Sessions whose return leg has no destination.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Checkout Sessions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Methods that take the customer off your page to authenticate.
// redirect_on_completion of "never" disables these outright.
export const REDIRECT_METHODS =
['ideal', 'bancontact', 'p24', 'sofort', 'eps', 'giropay', 'blik'];
// Stripe has spelled the ui_mode values differently across API versions, so
// accept both rather than reporting a pinned older version as unknown.
const EMBEDDED_MODES = ['embedded_page', 'embedded', 'elements'];
const HOSTED_MODES = ['hosted_page', 'hosted'];
export const PLACEHOLDER = '{CHECKOUT_SESSION_ID}';
/** Classify one Checkout Session. Pure, so the rules can be tested offline. */
export function verdict(session) {
const ui = session.ui_mode ?? HOSTED_MODES[0];
const methods = (session.payment_method_types ?? [])
.filter((m) => REDIRECT_METHODS.includes(m));
if (EMBEDDED_MODES.includes(ui)) {
if (session.redirect_on_completion === 'never' && methods.length) {
return ['blocked',
'redirect_on_completion=never disables redirect-based methods, so ' +
`${methods.join(', ')} are configured but never offered`];
}
if (!String(session.return_url ?? '').trim()) {
return ['stranded',
`ui_mode=${ui} with no return_url: a customer who authenticates ` +
'off-site comes back to nowhere'];
}
return ['ok', `ui_mode=${ui} with a return_url`];
}
if (HOSTED_MODES.includes(ui)) {
const success = String(session.success_url ?? '');
if (!success.includes(PLACEHOLDER)) {
return ['unjoinable',
`success_url is ${success || 'empty'}: no ${PLACEHOLDER} placeholder, ` +
'so the landing page cannot tell which session it is confirming'];
}
return ['ok', 'hosted, and success_url carries the session id'];
}
return ['unknown', `unrecognised ui_mode ${JSON.stringify(ui)}`];
}
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* sessions(key, since, limit = 5000) {
let seen = 0;
const params = { limit: 100, 'created[gte]': Math.floor(since) };
for (;;) {
const page = await get(key, '/checkout/sessions', params);
const data = page.data ?? [];
for (const s of data) { yield s; 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 days = Number(process.argv[2] ?? 30);
const tally = { ok: 0, stranded: 0, blocked: 0, unjoinable: 0, unknown: 0 };
const examples = [];
let total = 0;
for await (const s of sessions(key, Date.now() / 1000 - days * 86400)) {
total += 1;
const [state, detail] = verdict(s);
tally[state] = (tally[state] ?? 0) + 1;
if (state !== 'ok' && examples.length < 10) {
examples.push([state, s.id ?? '?', detail]);
}
}
console.log(`${total} session(s): ${tally.ok} ok, ${tally.stranded} stranded, ` +
`${tally.blocked} blocked, ${tally.unjoinable} unjoinable`);
for (const [state, id, detail] of examples) {
console.warn(`${state.padEnd(10)} ${id} ${detail}`);
}
if (tally.stranded || tally.blocked) {
console.warn(` repair: POST ${API}/checkout/sessions -d ui_mode=embedded_page ` +
`-d return_url='https://example.com/after-checkout` +
`?session_id=${PLACEHOLDER}' -d redirect_on_completion=if_required`);
}
if (tally.unjoinable) {
console.warn(` repair: POST ${API}/checkout/sessions ` +
`-d success_url='https://example.com/thanks?session_id=${PLACEHOLDER}'`);
}
process.exitCode = total - tally.ok ? 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 blocked one: an embedded session with a perfectly good return_url, iDEAL in payment_method_types, and redirect_on_completion of never. Every field is individually valid and the combination silently removes a payment method from the form, so a checker that stops at return_url reports it as fine.
from stripe_checkout_return_urls import verdict
RETURN = "https://example.com/after-checkout?session_id={CHECKOUT_SESSION_ID}"
def test_embedded_with_a_return_url_is_ok():
state, _ = verdict({"ui_mode": "embedded_page", "return_url": RETURN,
"redirect_on_completion": "if_required"})
assert state == "ok"
def test_embedded_without_a_return_url_is_stranded():
state, detail = verdict({"ui_mode": "embedded_page", "return_url": None})
assert state == "stranded"
assert "nowhere" in detail
assert verdict({"ui_mode": "embedded_page", "return_url": " "})[0] == "stranded"
def test_never_plus_a_redirect_method_beats_a_valid_return_url():
# Every field here is individually fine; together they remove iDEAL entirely.
state, detail = verdict({"ui_mode": "embedded_page", "return_url": RETURN,
"redirect_on_completion": "never",
"payment_method_types": ["card", "ideal"]})
assert state == "blocked"
assert "ideal" in detail
def test_hosted_success_url_without_the_placeholder_is_unjoinable():
state, detail = verdict({"ui_mode": "hosted_page",
"success_url": "https://example.com/thanks"})
assert state == "unjoinable"
assert "CHECKOUT_SESSION_ID" in detail
assert verdict({"success_url": "https://example.com/thanks"})[0] == "unjoinable"
def test_an_unrecognised_ui_mode_is_not_silently_ok():
assert verdict({"ui_mode": "kiosk"})[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-checkout-return-urls.mjs';
const RETURN = 'https://example.com/after-checkout?session_id={CHECKOUT_SESSION_ID}';
test('embedded with a return_url is ok', () => {
const [state] = verdict({ ui_mode: 'embedded_page', return_url: RETURN,
redirect_on_completion: 'if_required' });
assert.equal(state, 'ok');
});
test('embedded without a return_url is stranded', () => {
const [state, detail] = verdict({ ui_mode: 'embedded_page', return_url: null });
assert.equal(state, 'stranded');
assert.match(detail, /nowhere/);
assert.equal(
verdict({ ui_mode: 'embedded_page', return_url: ' ' })[0], 'stranded');
});
test('never plus a redirect method beats a valid return_url', () => {
const [state, detail] = verdict({ ui_mode: 'embedded_page', return_url: RETURN,
redirect_on_completion: 'never', payment_method_types: ['card', 'ideal'] });
assert.equal(state, 'blocked');
assert.match(detail, /ideal/);
});
test('hosted success_url without the placeholder is unjoinable', () => {
const [state, detail] = verdict({ ui_mode: 'hosted_page',
success_url: 'https://example.com/thanks' });
assert.equal(state, 'unjoinable');
assert.match(detail, /CHECKOUT_SESSION_ID/);
assert.equal(
verdict({ success_url: 'https://example.com/thanks' })[0], 'unjoinable');
});
test('an unrecognised ui_mode is not silently ok', () => {
assert.equal(verdict({ ui_mode: 'kiosk' })[0], 'unknown');
});
FAQ
What is the difference between return_url and success_url?
success_url is where the hosted Checkout page sends the customer once checkout finishes. return_url is where the customer comes back to after authenticating somewhere else: their bank for iDEAL or Bancontact, or a 3DS challenge page. Embedded Checkout uses return_url, and a success_url does not stand in for it.
Why did iDEAL stop appearing in my embedded form?
Almost certainly redirect_on_completion=never. It does not only suppress the completion redirect; it disables redirect-based payment methods altogether. They stay listed in payment_method_types and are simply never offered, which is why nothing errors and the only symptom is a region that stopped converting.
Should return_url carry the session id?
Yes. Add ?session_id={CHECKOUT_SESSION_ID} and Stripe substitutes the real id, so the page the customer lands on can retrieve the session and show them what they bought. Without it the page knows something finished but not what.
Is redirect_on_completion=if_required safe to use?
That is the setting to want. The customer stays on your page for methods that do not need to leave it, and is redirected only for the ones that do, which keeps the embedded experience without silently removing payment methods from it.
Can I fulfil the order on the return page?
Not as the only trigger. The customer can close the tab, lose signal, or be dropped by their bank's redirect, and none of that stops the payment succeeding. Fulfil on checkout.session.completed and use the return page to tell the customer what happened.
Related field notes
- Session status is complete but payment_status is still unpaid
- Payment Link ends on Stripe's page, so fulfilment never fires
- Checkout Sessions carry no ID that maps back to your order
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.
- The Checkout Session object — Stripe API reference
- Create a Checkout Session — Stripe API reference
- Embedded Checkout quickstart — Stripe Docs
- Fulfill orders after checkout — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.