Reconciler Payments & Refunds
Stripe capture succeeds but no Medusa order is created
Stripe's dashboard shows the charge as succeeded. The customer's card was debited. But there is no order in Medusa Admin, the cart is still sitting there with no completed_at, and support has no idea what to tell the customer who is asking where their confirmation email is. The money moved. Medusa never turned that cart into an order. Here is why that gap exists and a reconciler that finds it and repairs it the safe way.
Medusa v2 only creates an order when completeCartWorkflow runs to completion, normally triggered by the storefront's POST /store/carts/{id}/complete call right after Stripe confirms the PaymentIntent client side. If the browser tab or network drops between that confirmation and the completion request reaching Medusa, or the async payment_intent.succeeded webhook races ahead of or instead of the synchronous completion call, Stripe has captured the money but Medusa never ran the workflow that creates the order. This exact scenario is called out in Medusa's own payment webhook-events documentation. Run a small Python or Node.js script that cross-references recent Stripe PaymentIntents against Medusa's /admin/payments, flags any captured PaymentIntent with no matching cart completion as an orphaned capture, and behind a DRY_RUN guard retries the exact same /store/carts/{id}/complete route the storefront would have called. Full code, tests, and citations are below.
The problem in plain words
In Medusa v2, an order does not exist just because a payment succeeded. A cart becomes an order only when completeCartWorkflow runs end to end: it confirms the payment session, creates the order record, sets up fulfillment, and stamps completed_at on the cart. The storefront kicks that workflow off with one call, POST /store/carts/{id}/complete, right after the client-side Stripe confirmation resolves.
That call is synchronous and it depends on the customer's browser actually making it. If the tab closes, the network drops, or the page navigates away in the half second between Stripe confirming the PaymentIntent and that completion request landing at Medusa, the workflow simply never runs. Stripe already has the money. Medusa never got the signal to do anything with it. Separately, Stripe also sends an asynchronous payment_intent.succeeded webhook, and if that webhook races ahead of or instead of the synchronous completion call, the same gap opens: a captured PaymentIntent with metadata pointing at a cart that never got completed.
Why it happens
Every one of these is a real gap in how the pieces connect, not a single bug with one cause:
- An order is only created when
completeCartWorkflowruns to completion, and the storefront is the one that triggers it withPOST /store/carts/{id}/completeright after the client-side payment confirms. - Medusa's own payment webhook-events documentation names this exact use case: "a payment action on the frontend was interrupted, leading the payment to be processed without an order being created."
- The asynchronous
payment_intent.succeededwebhook and the synchronous completion call are two independent paths. If the webhook fires and the completion call never does, or the two race in the wrong order, Stripe's PaymentIntent metadata still points at acart_idthat never got itscompleted_atset. - Multiple GitHub issues (#12790, #12481, #12399) confirm this in the wild across Medusa v2.7 through v2.11, with reports of a successful Stripe Payment Element charge and no matching order in the Admin panel.
- Medusa's v2.8.0 changelog acknowledges the underlying fragility directly: it changed
completeCartWorkflow'sidempotentflag fromtruetofalsespecifically so a stalled completion can be retried instead of permanently returning a cached failure.
This is a common source of confusion because Stripe's own dashboard shows the charge as settled, so it looks like a Medusa display bug rather than a workflow that simply never ran. See the citations at the end for the exact issues and docs.
You cannot safely fabricate an order to match a Stripe capture. Completing a cart on behalf of a customer touches inventory reservations, tax and shipping recalculation, and the exact totals Medusa itself would have produced, and inserting an order directly through the Admin API skips all of that and risks double fulfillment. The safe pattern is to call the real completion path Medusa already exposes for this recovery case, POST /store/carts/{id}/complete, the same route the storefront calls, which is designed to be retried since idempotent was set to false in v2.8.0 for exactly this reason.
The fix, as a flow
We do not touch checkout and we do not insert a synthetic order. We pull recent Stripe PaymentIntents, match each one against Medusa's /admin/payments by the Stripe id stored in Payment.data.id, and flag any captured PaymentIntent whose cart has no completed_at and no order relation. Behind DRY_RUN, the repair step re-verifies the cart is still incomplete and then calls /store/carts/{id}/complete, the same route the storefront would have called.
Build it step by step
Get Stripe and Medusa credentials in one place
You need a Stripe secret key to list PaymentIntents, and a Medusa admin session to read payments and carts, plus a storefront publishable API key to call the store completion route during repair. Keep all four in environment variables, never in the file, and leave DRY_RUN on until you have reviewed the flagged list.
pip install requests
export STRIPE_SECRET_KEY="sk_live_..."
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export GRACE_MINUTES="10" # ignore captures younger than this
export DRY_RUN="true" # start safe, only logs orphaned captures
// Node 18+ has fetch built in, no dependencies needed
export STRIPE_SECRET_KEY="sk_live_..."
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export GRACE_MINUTES="10" // ignore captures younger than this
export DRY_RUN="true" // start safe, only logs orphaned captures
Pull recent succeeded Stripe PaymentIntents
List PaymentIntents from the Stripe API filtered to a recent window, keeping the id, the captured timestamp, the amount, and any cart_id metadata Medusa attached during initiatePayment. This is the source list every check runs against.
import os, time, requests
STRIPE_KEY = os.environ["STRIPE_SECRET_KEY"]
STRIPE_API = "https://api.stripe.com/v1"
def recent_succeeded_payment_intents(lookback_hours=24):
out, starting_after = [], None
since = int(time.time()) - lookback_hours * 3600
while True:
params = {"limit": 100, "created[gte]": since}
if starting_after:
params["starting_after"] = starting_after
r = requests.get(
f"{STRIPE_API}/payment_intents",
params=params,
auth=(STRIPE_KEY, ""),
timeout=30,
)
r.raise_for_status()
body = r.json()
for pi in body["data"]:
if pi["status"] == "succeeded":
out.append(pi)
if not body.get("has_more"):
return out
starting_after = body["data"][-1]["id"]
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY;
const STRIPE_API = "https://api.stripe.com/v1";
async function recentSucceededPaymentIntents(lookbackHours = 24) {
const out = [];
let startingAfter = null;
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
while (true) {
const params = new URLSearchParams({ limit: "100", "created[gte]": String(since) });
if (startingAfter) params.set("starting_after", startingAfter);
const res = await fetch(`${STRIPE_API}/payment_intents?${params}`, {
headers: { Authorization: `Basic ${Buffer.from(STRIPE_KEY + ":").toString("base64")}` },
});
if (!res.ok) throw new Error(`Stripe ${res.status}`);
const body = await res.json();
for (const pi of body.data) if (pi.status === "succeeded") out.push(pi);
if (!body.has_more) return out;
startingAfter = body.data[body.data.length - 1].id;
}
}
Match against Medusa payments and read the cart's state
Medusa stores the Stripe PaymentIntent id inside the Payment's own data JSONB blob, so read /admin/payments with data expanded, page through, and collect every payment.data.id you see. For any Stripe id not in that set, use the PaymentIntent's cart_id metadata to look up the cart directly with GET /store/carts/{cart_id} and read completed_at and whether an order resolved.
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "pk_dummy")
def get_admin_token(email, password):
r = requests.post(f"{BASE_URL}/auth/user/emailpass",
json={"email": email, "password": password}, timeout=30)
r.raise_for_status()
return r.json()["token"]
def all_medusa_payment_data_ids(token):
headers = {"Authorization": f"Bearer {token}"}
ids, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/payments",
params={"fields": "id,data", "limit": limit, "offset": offset},
headers=headers, timeout=30,
)
r.raise_for_status()
body = r.json()
for payment in body["payments"]:
pid = (payment.get("data") or {}).get("id")
if pid:
ids.append(pid)
offset += limit
if offset >= body["count"]:
return ids
def get_cart(cart_id):
r = requests.get(
f"{BASE_URL}/store/carts/{cart_id}",
headers={"x-publishable-api-key": PUBLISHABLE_KEY},
timeout=30,
)
r.raise_for_status()
return r.json()["cart"]
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "pk_dummy";
async function getAdminToken(email, password) {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
return (await res.json()).token;
}
async function allMedusaPaymentDataIds(token) {
const ids = [];
let offset = 0;
const limit = 100;
while (true) {
const res = await fetch(
`${BASE_URL}/admin/payments?fields=id,data&limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
for (const payment of body.payments) {
const pid = payment.data?.id;
if (pid) ids.push(pid);
}
offset += limit;
if (offset >= body.count) return ids;
}
}
async function getCart(cartId) {
const res = await fetch(`${BASE_URL}/store/carts/${cartId}`, {
headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).cart;
}
Decide, with one pure function
Keep the decision in a function with no network calls. It takes the Stripe PaymentIntent's status and timing, the full list of Medusa payment.data.id values, and the cart's completed_at and order relation, and returns one of four outcomes: ok, too_recent, orphaned_capture_needs_manual_complete, or already_reconciled. The grace window keeps it from flagging a capture whose webhook simply has not landed yet.
def decide_reconciliation(
stripe_payment_intent_id,
stripe_status,
captured_at_ms,
now_ms,
grace_ms,
medusa_payment_data_ids,
cart_completed_at,
cart_has_order_id,
):
"""Pure: no I/O. Returns one of ok, too_recent,
orphaned_capture_needs_manual_complete, already_reconciled."""
matched_in_medusa = stripe_payment_intent_id in medusa_payment_data_ids
if matched_in_medusa and (cart_completed_at is not None or cart_has_order_id):
return "already_reconciled"
if stripe_status != "succeeded":
return "ok" # nothing captured yet, not our problem
if now_ms - captured_at_ms < grace_ms:
return "too_recent" # webhook may still be in flight, don't flag yet
if not matched_in_medusa and cart_completed_at is None and not cart_has_order_id:
return "orphaned_capture_needs_manual_complete"
return "ok"
export function decideReconciliation({
stripePaymentIntentId,
stripeStatus,
capturedAtMs,
nowMs,
graceMs,
medusaPaymentDataIds,
cartCompletedAt,
cartHasOrderId,
}) {
const matchedInMedusa = medusaPaymentDataIds.includes(stripePaymentIntentId);
if (matchedInMedusa && (cartCompletedAt !== null || cartHasOrderId)) {
return "already_reconciled";
}
if (stripeStatus !== "succeeded") {
return "ok"; // nothing captured yet, not our problem
}
if (nowMs - capturedAtMs < graceMs) {
return "too_recent"; // webhook may still be in flight, don't flag yet
}
if (!matchedInMedusa && cartCompletedAt === null && !cartHasOrderId) {
return "orphaned_capture_needs_manual_complete";
}
return "ok";
}
Repair by retrying cart completion, never by inserting an order
For each orphaned capture, re-verify with a fresh GET /store/carts/{cart_id} that completed_at is still null, since the cart may have completed between detection and repair. Then call POST /store/carts/{cart_id}/complete with the storefront's x-publishable-api-key header, the exact route the storefront calls after checkout. If that call itself fails, for example because stock is no longer available, stop there and surface it to support with the Stripe PaymentIntent id attached rather than inserting a synthetic order.
def complete_cart(cart_id):
r = requests.post(
f"{BASE_URL}/store/carts/{cart_id}/complete",
headers={"x-publishable-api-key": PUBLISHABLE_KEY},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("type") == "order":
return body["order"]
raise RuntimeError(f"cart {cart_id} did not complete into an order: {body}")
async function completeCart(cartId) {
const res = await fetch(`${BASE_URL}/store/carts/${cartId}/complete`, {
method: "POST",
headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
if (body.type === "order") return body.order;
throw new Error(`cart ${cartId} did not complete into an order: ${JSON.stringify(body)}`);
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports the {stripe_payment_intent_id, cart_id, amount, captured_at, medusa_status} records it would repair. Read the output, agree with it, then switch it off to let it re-verify each cart and retry completion for real. Anything that fails the retry gets logged for a human with the Stripe PaymentIntent id attached.
Never insert an order directly through the Admin API to match a Stripe capture. That skips inventory reservations, tax and shipping recalculation, and risks double fulfillment. Always start with DRY_RUN=true, and if the retried cart completion itself fails, hand it to support for manual reconciliation through /admin/draft-orders instead of forcing a fix.
The full code
Here is the complete script in one file for each language. It pulls recent succeeded Stripe PaymentIntents, matches them against Medusa's payments, flags every orphaned capture with a pure function, and either logs the repair or retries cart completion depending on DRY_RUN, confirming the result after.
"""Find Stripe PaymentIntents that captured money with no matching Medusa order,
and repair them the safe way. Never inserts a synthetic order through the Admin
API. DRY_RUN=true only logs the reconciliation records it would act on. The one
safe repair is retrying POST /store/carts/{id}/complete, the same route the
storefront already calls, since completeCartWorkflow's idempotent flag was set
to false in Medusa v2.8.0 specifically so a stalled completion can be retried.
"""
import os
import time
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_orphaned_captures")
STRIPE_KEY = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
STRIPE_API = "https://api.stripe.com/v1"
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "pk_dummy")
GRACE_MS = float(os.environ.get("GRACE_MINUTES", "10")) * 60 * 1000
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def decide_reconciliation(
stripe_payment_intent_id,
stripe_status,
captured_at_ms,
now_ms,
grace_ms,
medusa_payment_data_ids,
cart_completed_at,
cart_has_order_id,
):
"""Pure: no I/O. Returns one of ok, too_recent,
orphaned_capture_needs_manual_complete, already_reconciled."""
matched_in_medusa = stripe_payment_intent_id in medusa_payment_data_ids
if matched_in_medusa and (cart_completed_at is not None or cart_has_order_id):
return "already_reconciled"
if stripe_status != "succeeded":
return "ok" # nothing captured yet, not our problem
if now_ms - captured_at_ms < grace_ms:
return "too_recent" # webhook may still be in flight, don't flag yet
if not matched_in_medusa and cart_completed_at is None and not cart_has_order_id:
return "orphaned_capture_needs_manual_complete"
return "ok"
def recent_succeeded_payment_intents(lookback_hours=24):
out, starting_after = [], None
since = int(time.time()) - lookback_hours * 3600
while True:
params = {"limit": 100, "created[gte]": since}
if starting_after:
params["starting_after"] = starting_after
r = requests.get(
f"{STRIPE_API}/payment_intents",
params=params,
auth=(STRIPE_KEY, ""),
timeout=30,
)
r.raise_for_status()
body = r.json()
for pi in body["data"]:
if pi["status"] == "succeeded":
out.append(pi)
if not body.get("has_more"):
return out
starting_after = body["data"][-1]["id"]
def get_admin_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def all_medusa_payment_data_ids(token):
headers = {"Authorization": f"Bearer {token}"}
ids, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/payments",
params={"fields": "id,data", "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for payment in body["payments"]:
pid = (payment.get("data") or {}).get("id")
if pid:
ids.append(pid)
offset += limit
if offset >= body["count"]:
return ids
def get_cart(cart_id):
r = requests.get(
f"{BASE_URL}/store/carts/{cart_id}",
headers={"x-publishable-api-key": PUBLISHABLE_KEY},
timeout=30,
)
r.raise_for_status()
return r.json()["cart"]
def complete_cart(cart_id):
r = requests.post(
f"{BASE_URL}/store/carts/{cart_id}/complete",
headers={"x-publishable-api-key": PUBLISHABLE_KEY},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("type") == "order":
return body["order"]
raise RuntimeError(f"cart {cart_id} did not complete into an order: {body}")
def run():
token = get_admin_token()
medusa_payment_data_ids = all_medusa_payment_data_ids(token)
payment_intents = recent_succeeded_payment_intents()
now_ms = time.time() * 1000
flagged = []
for pi in payment_intents:
cart_id = (pi.get("metadata") or {}).get("cart_id")
if not cart_id:
continue
cart = get_cart(cart_id)
outcome = decide_reconciliation(
stripe_payment_intent_id=pi["id"],
stripe_status=pi["status"],
captured_at_ms=pi["created"] * 1000,
now_ms=now_ms,
grace_ms=GRACE_MS,
medusa_payment_data_ids=medusa_payment_data_ids,
cart_completed_at=cart.get("completed_at"),
cart_has_order_id=bool(cart.get("order")),
)
if outcome == "orphaned_capture_needs_manual_complete":
flagged.append((pi, cart_id))
if not flagged:
log.info("No orphaned captures found across %d succeeded PaymentIntent(s).", len(payment_intents))
return
for pi, cart_id in flagged:
log.warning(
"Orphaned capture: PI %s amount=%s cart=%s captured_at=%s. %s",
pi["id"], pi["amount"], cart_id, pi["created"],
"Would retry cart complete" if DRY_RUN else "Retrying cart complete",
)
if DRY_RUN:
continue
fresh_cart = get_cart(cart_id)
if fresh_cart.get("completed_at") or fresh_cart.get("order"):
log.info("Cart %s completed between detection and repair. Skipping.", cart_id)
continue
try:
order = complete_cart(cart_id)
log.info("Cart %s completed into order %s.", cart_id, order.get("id"))
except Exception as exc:
log.error(
"Cart %s failed to complete for PI %s: %s. "
"Flagging to support for manual /admin/draft-orders reconciliation.",
cart_id, pi["id"], exc,
)
log.info("Done. %d orphaned capture(s) %s.", len(flagged), "to review" if DRY_RUN else "processed")
if __name__ == "__main__":
run()
/**
* Find Stripe PaymentIntents that captured money with no matching Medusa order,
* and repair them the safe way. Never inserts a synthetic order through the
* Admin API. DRY_RUN=true only logs the reconciliation records it would act
* on. The one safe repair is retrying POST /store/carts/{id}/complete, the
* same route the storefront already calls, since completeCartWorkflow's
* idempotent flag was set to false in Medusa v2.8.0 specifically so a stalled
* completion can be retried.
*/
import { pathToFileURL } from "node:url";
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || "sk_test_dummy";
const STRIPE_API = "https://api.stripe.com/v1";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "pk_dummy";
const GRACE_MS = Number(process.env.GRACE_MINUTES || 10) * 60 * 1000;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideReconciliation({
stripePaymentIntentId,
stripeStatus,
capturedAtMs,
nowMs,
graceMs,
medusaPaymentDataIds,
cartCompletedAt,
cartHasOrderId,
}) {
const matchedInMedusa = medusaPaymentDataIds.includes(stripePaymentIntentId);
if (matchedInMedusa && (cartCompletedAt !== null || cartHasOrderId)) {
return "already_reconciled";
}
if (stripeStatus !== "succeeded") {
return "ok"; // nothing captured yet, not our problem
}
if (nowMs - capturedAtMs < graceMs) {
return "too_recent"; // webhook may still be in flight, don't flag yet
}
if (!matchedInMedusa && cartCompletedAt === null && !cartHasOrderId) {
return "orphaned_capture_needs_manual_complete";
}
return "ok";
}
async function recentSucceededPaymentIntents(lookbackHours = 24) {
const out = [];
let startingAfter = null;
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
while (true) {
const params = new URLSearchParams({ limit: "100", "created[gte]": String(since) });
if (startingAfter) params.set("starting_after", startingAfter);
const res = await fetch(`${STRIPE_API}/payment_intents?${params}`, {
headers: { Authorization: `Basic ${Buffer.from(STRIPE_KEY + ":").toString("base64")}` },
});
if (!res.ok) throw new Error(`Stripe ${res.status}`);
const body = await res.json();
for (const pi of body.data) if (pi.status === "succeeded") out.push(pi);
if (!body.has_more) return out;
startingAfter = body.data[body.data.length - 1].id;
}
}
async function getAdminToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
return (await res.json()).token;
}
async function allMedusaPaymentDataIds(token) {
const ids = [];
let offset = 0;
const limit = 100;
while (true) {
const res = await fetch(
`${BASE_URL}/admin/payments?fields=id,data&limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
for (const payment of body.payments) {
const pid = payment.data?.id;
if (pid) ids.push(pid);
}
offset += limit;
if (offset >= body.count) return ids;
}
}
async function getCart(cartId) {
const res = await fetch(`${BASE_URL}/store/carts/${cartId}`, {
headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).cart;
}
async function completeCart(cartId) {
const res = await fetch(`${BASE_URL}/store/carts/${cartId}/complete`, {
method: "POST",
headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
if (body.type === "order") return body.order;
throw new Error(`cart ${cartId} did not complete into an order: ${JSON.stringify(body)}`);
}
export async function run() {
const token = await getAdminToken();
const medusaPaymentDataIds = await allMedusaPaymentDataIds(token);
const paymentIntents = await recentSucceededPaymentIntents();
const nowMs = Date.now();
const flagged = [];
for (const pi of paymentIntents) {
const cartId = pi.metadata?.cart_id;
if (!cartId) continue;
const cart = await getCart(cartId);
const outcome = decideReconciliation({
stripePaymentIntentId: pi.id,
stripeStatus: pi.status,
capturedAtMs: pi.created * 1000,
nowMs,
graceMs: GRACE_MS,
medusaPaymentDataIds,
cartCompletedAt: cart.completed_at ?? null,
cartHasOrderId: Boolean(cart.order),
});
if (outcome === "orphaned_capture_needs_manual_complete") {
flagged.push([pi, cartId]);
}
}
if (flagged.length === 0) {
console.log(`No orphaned captures found across ${paymentIntents.length} succeeded PaymentIntent(s).`);
return;
}
for (const [pi, cartId] of flagged) {
console.warn(
`Orphaned capture: PI ${pi.id} amount=${pi.amount} cart=${cartId} captured_at=${pi.created}. ${DRY_RUN ? "Would retry cart complete" : "Retrying cart complete"}`
);
if (DRY_RUN) continue;
const freshCart = await getCart(cartId);
if (freshCart.completed_at || freshCart.order) {
console.log(`Cart ${cartId} completed between detection and repair. Skipping.`);
continue;
}
try {
const order = await completeCart(cartId);
console.log(`Cart ${cartId} completed into order ${order.id}.`);
} catch (err) {
console.error(
`Cart ${cartId} failed to complete for PI ${pi.id}: ${err.message}. Flagging to support for manual /admin/draft-orders reconciliation.`
);
}
}
console.log(`Done. ${flagged.length} orphaned capture(s) ${DRY_RUN ? "to review" : "processed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing is the one that decides the outcome, decide_reconciliation. It is pure, no network and no Stripe or Medusa account, so the tests feed in plain values and check the answer.
from reconcile_orphaned_captures import decide_reconciliation
GRACE_MS = 10 * 60 * 1000
NOW_MS = 1_800_000_000_000
def call(**over):
base = dict(
stripe_payment_intent_id="pi_123",
stripe_status="succeeded",
captured_at_ms=NOW_MS - GRACE_MS * 2,
now_ms=NOW_MS,
grace_ms=GRACE_MS,
medusa_payment_data_ids=[],
cart_completed_at=None,
cart_has_order_id=False,
)
base.update(over)
return decide_reconciliation(**base)
def test_orphaned_when_captured_unmatched_and_cart_incomplete():
assert call() == "orphaned_capture_needs_manual_complete"
def test_already_reconciled_when_matched_and_cart_completed():
result = call(medusa_payment_data_ids=["pi_123"], cart_completed_at="2026-07-10T00:00:00Z")
assert result == "already_reconciled"
def test_already_reconciled_when_matched_and_has_order():
result = call(medusa_payment_data_ids=["pi_123"], cart_has_order_id=True)
assert result == "already_reconciled"
def test_ok_when_stripe_status_not_succeeded():
assert call(stripe_status="processing") == "ok"
def test_too_recent_within_grace_window():
result = call(captured_at_ms=NOW_MS - 1000)
assert result == "too_recent"
def test_ok_when_matched_but_cart_still_incomplete():
# matched in Medusa payments, but the cart genuinely has no completed_at/order
# yet is not orphaned since the first branch only triggers on completion
result = call(medusa_payment_data_ids=["pi_123"])
assert result == "ok"
def test_exactly_at_grace_boundary_is_flagged():
result = call(captured_at_ms=NOW_MS - GRACE_MS)
assert result == "orphaned_capture_needs_manual_complete"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReconciliation } from "./reconcile-orphaned-captures.js";
const GRACE_MS = 10 * 60 * 1000;
const NOW_MS = 1_800_000_000_000;
const call = (over = {}) => decideReconciliation({
stripePaymentIntentId: "pi_123",
stripeStatus: "succeeded",
capturedAtMs: NOW_MS - GRACE_MS * 2,
nowMs: NOW_MS,
graceMs: GRACE_MS,
medusaPaymentDataIds: [],
cartCompletedAt: null,
cartHasOrderId: false,
...over,
});
test("orphaned when captured, unmatched, and cart incomplete", () => {
assert.equal(call(), "orphaned_capture_needs_manual_complete");
});
test("already reconciled when matched and cart completed", () => {
const result = call({ medusaPaymentDataIds: ["pi_123"], cartCompletedAt: "2026-07-10T00:00:00Z" });
assert.equal(result, "already_reconciled");
});
test("already reconciled when matched and has order", () => {
const result = call({ medusaPaymentDataIds: ["pi_123"], cartHasOrderId: true });
assert.equal(result, "already_reconciled");
});
test("ok when stripe status is not succeeded", () => {
assert.equal(call({ stripeStatus: "processing" }), "ok");
});
test("too recent within grace window", () => {
const result = call({ capturedAtMs: NOW_MS - 1000 });
assert.equal(result, "too_recent");
});
test("ok when matched but cart still incomplete", () => {
const result = call({ medusaPaymentDataIds: ["pi_123"] });
assert.equal(result, "ok");
});
test("exactly at grace boundary is flagged", () => {
const result = call({ capturedAtMs: NOW_MS - GRACE_MS });
assert.equal(result, "orphaned_capture_needs_manual_complete");
});
Case studies
The customer who closed the tab too early
A store selling on mobile saw a cluster of support tickets asking where the confirmation email was, while the customer's bank statement clearly showed the charge. The pattern was always the same: a slow mobile network, a Stripe confirmation that succeeded, and a tab the customer closed a second too early, before the storefront's completion call finished.
Running the reconciler on a schedule caught every one of these within the hour. Each flagged PaymentIntent's cart_id metadata pointed at a cart still sitting with no completed_at. Retrying /store/carts/{id}/complete turned every one of them into a real order, with inventory reservations and tax recalculated exactly the way the original checkout would have done it.
The webhook that arrived before the completion call
A team running a busy storefront noticed a handful of orphaned captures appeared right after a deploy that briefly slowed down their backend response times. The async payment_intent.succeeded webhook from Stripe was landing and being processed, while the synchronous completion call from the same checkout was still queued behind the slowdown and eventually timed out client side.
The grace window kept the reconciler from flagging captures that were simply still in flight. Past that window, the ones that stayed unmatched were genuine gaps. Most cleared on the first retry of cart completion. One did not, because the last unit of a limited edition item had sold out in the meantime, and that one went straight to support with the Stripe PaymentIntent id attached for a manual draft order.
Run this reconciler on a schedule against your recent Stripe captures. It never fabricates an order, so it can never create a mismatch between what Stripe charged and what Medusa's inventory and tax workflows actually processed. It repairs the same way the storefront's own checkout does, by retrying /store/carts/{id}/complete on the cart that actually has the captured payment, then confirms an order came out the other side. Anything that still fails after that gets handed to support with the Stripe PaymentIntent id, because that is a stock or data problem, not a timing gap a script should paper over.
FAQ
Why did Stripe capture the payment but Medusa never created an order?
Medusa v2 only creates an order when completeCartWorkflow runs to completion, which normally happens through the storefront's POST /store/carts/{id}/complete call right after Stripe confirms the PaymentIntent. If the browser tab or network drops between that confirmation and the completion request reaching Medusa, or the async payment_intent.succeeded webhook races the synchronous completion call, Stripe has the money but Medusa never ran the workflow that turns the cart into an order.
Is it safe to auto-create an order for an orphaned Stripe capture?
No, not by fabricating one. Auto-completing a cart touches inventory reservations, tax and shipping recalculation, and can double-charge if misdiagnosed, so the safe default is to flag and report first. The one safe repair is calling the same POST /store/carts/{id}/complete route the storefront already calls, since Medusa's own completeCartWorkflow is designed to be retried.
What if retrying cart completion still fails after a confirmed Stripe capture?
Do not fabricate an order through the Admin API, since that bypasses inventory and tax workflows and risks double fulfillment. Surface the cart and the Stripe PaymentIntent id to support for manual reconciliation through /admin/draft-orders instead, most commonly because stock is no longer available for the retry.
Related field notes
Citations
On the problem:
- Medusa Documentation: Payment Webhook Events, including the case of a payment processed without an order being created. docs.medusajs.com/resources/commerce-modules/payment/webhook-events
- medusajs/medusa GitHub issue #12790: Stripe Payment succeeds but no order in Medusa Admin Panel with the Stripe Payment Element. github.com/medusajs/medusa/issues/12790
- medusajs/medusa GitHub issue #12481: Unable to complete cart a second time after failing at the authorize-payment-session step in completeCartWorkflow on v2.8.0. github.com/medusajs/medusa/issues/12481
On the solution:
- Medusa Core Workflows Reference: completeCartWorkflow. docs.medusajs.com/resources/references/medusa-workflows/completeCartWorkflow
- Medusa Documentation: Checkout Step 5, Complete Cart. docs.medusajs.com/resources/storefront-development/checkout/complete-cart
- Medusa Documentation: Stripe Module Provider. docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up an orphaned capture?
If this saved you a confusing support ticket or a scary manual order, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi