Reconciler Payment lifecycle
Slow methods succeed but never match
SOFORT and Klarna do not confirm at checkout the way a card does. The buyer is sent back to your store with an order still on Pending, and the real confirmation shows up later from a bank or a lender, sometimes hours later. When that follow up message is missed, the PaymentIntent quietly moves to succeeded on Stripe while the order never catches up. Here is why the two fall out of sync and a small script that finds the late charge and matches it back to its order.
SOFORT and Klarna are delayed notification methods. The order is created as Pending payment before the bank or lender has actually confirmed anything, and the real confirmation can land minutes or hours after the buyer left your site. If the webhook for that late confirmation is missed, the PaymentIntent reaches succeeded on Stripe while WooCommerce never hears about it. Run a small Python or Node.js reconciler on a schedule that lists succeeded PaymentIntents created with a delayed method, reads the order id from the PaymentIntent metadata or the order's saved _stripe_intent_id, and moves any order still stuck on Pending to Processing once the amount and currency match. Full code, tests, and a dry run guard are below.
The problem in plain words
A card payment is fast. Stripe confirms it in the same request, the buyer sees the thank you page, and the order moves to Processing right away. SOFORT and Klarna do not work like that. They are bank transfer and buy now pay later methods, so Stripe has to wait for a second system, a bank or a lender, to say the money actually cleared.
That wait means checkout ends with the order sitting on Pending payment on purpose. The plan is that a webhook arrives later, once the PaymentIntent moves to succeeded, and that webhook is what finishes the order. If that one later message is missed, the order is left exactly where checkout left it, even though Stripe eventually shows the payment as fully succeeded.
Why it happens
Stripe's own docs describe SOFORT, Klarna, and similar options as delayed notification payment methods, meaning the final status can take anywhere from a few minutes to several days to arrive. WooCommerce and the Stripe gateway are built to handle that with a follow up webhook, but that second message is easy to lose:
- The buyer closes the bank tab or the Klarna app before the redirect back to your store completes, so the order is left mid flight with no immediate signal either way.
- The webhook endpoint that was healthy at checkout time goes down, gets rate limited, or times out hours later when the delayed confirmation actually arrives.
- A caching or security layer treats the delayed webhook as a duplicate or stale request and drops it, since it looks unrelated to the visit that just happened.
- The store's webhook is only subscribed to a narrow set of event types and the specific succeeded event for that payment method was never selected in the Stripe dashboard.
The result is a quiet class of orders that look abandoned in WooCommerce but are actually paid in full on Stripe. They rarely get noticed until a buyer emails asking why their SOFORT or Klarna order was never shipped.
With delayed notification methods, Pending payment is the expected first state, not a sign of trouble by itself. The signal to act on is not "the order is Pending," it is "Stripe has moved this PaymentIntent to succeeded and the order is still Pending." A reconciler that checks for exactly that gap, and nothing else, can safely close it.
The fix, as a flow
We add a job that runs on a schedule and looks back a few days, since SOFORT and Klarna confirmations can take longer than a single business day. It lists PaymentIntents that are now succeeded and were created with a delayed method, reads the order id from metadata or from the order's own saved PaymentIntent id, and only touches orders that are still unpaid with a matching amount and currency. Everything else is left alone.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair, a consumer key and a consumer secret, with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="72"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="72"
export DRY_RUN="true" // start safe, change to false to write
List recently succeeded PaymentIntents
Ask Stripe for PaymentIntents created inside a wide lookback window, since SOFORT and Klarna can take longer than a day to confirm. Keep only the ones that are now succeeded and that used a delayed method, so we do not waste time re-checking card payments that were already settled at checkout.
import os, time, stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
DELAYED_METHODS = {"sofort", "klarna", "sepa_debit", "bancontact", "ideal"}
def recent_succeeded_delayed(lookback_hours):
since = int(time.time()) - lookback_hours * 3600
for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
if intent.status != "succeeded":
continue
methods = set(intent.get("payment_method_types") or [])
if methods & DELAYED_METHODS and intent.metadata.get("order_id"):
yield intent
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const DELAYED_METHODS = new Set(["sofort", "klarna", "sepa_debit", "bancontact", "ideal"]);
async function* recentSucceededDelayed(lookbackHours) {
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
if (intent.status !== "succeeded") continue;
const methods = intent.payment_method_types || [];
const isDelayed = methods.some((m) => DELAYED_METHODS.has(m));
if (isDelayed && intent.metadata.order_id) yield intent;
}
}
Load the matching WooCommerce order
Read the order through the WooCommerce REST API so the code works the same whether High Performance Order Storage (HPOS) is on or not. If the PaymentIntent has no order_id in metadata, fall back to searching orders whose saved _stripe_intent_id meta or transaction_id matches the intent, since some delayed method flows save the id slightly differently.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def find_order_by_intent(intent_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
matches = r.json()
return matches[0] if matches else None
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function getOrder(orderId) {
return woo(`/orders/${orderId}`);
}
async function findOrderByIntent(intentId) {
const matches = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
return matches && matches.length ? matches[0] : null;
}
Decide, with one pure function
Keep the matching rule in its own function that takes an order and an intent and returns an action, so it has no network calls and is easy to test. Skip anything already paid, cancelled, or refunded. Skip anything where the amount or currency does not match, since that points to a different order than the one named in metadata. Otherwise, the late charge and the order agree, so fix it.
PAID_STATUSES = {"processing", "completed"}
CLOSED_STATUSES = {"cancelled", "refunded", "failed", "trash"}
def order_amount_minor(order):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# need their own rounding rule, since 50.00 is wrong for those.
return round(float(order["total"]) * 100)
def decide(order, intent):
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded")
if order is None:
return ("orphan", "order not found")
if order["status"] in PAID_STATUSES:
return ("skip", "order already paid")
if order["status"] in CLOSED_STATUSES:
return ("skip", "order already closed")
if order.get("currency", "").lower() != intent.get("currency", "").lower():
return ("mismatch", "currency does not match")
if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
return ("mismatch", "amount does not match")
return ("fix", "delayed method succeeded, order never caught up")
const PAID_STATUSES = new Set(["processing", "completed"]);
const CLOSED_STATUSES = new Set(["cancelled", "refunded", "failed", "trash"]);
export function orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// need their own rounding rule, since 50.00 is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, intent) {
if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
if (!order) return ["orphan", "order not found"];
if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
if (CLOSED_STATUSES.has(order.status)) return ["skip", "order already closed"];
if ((order.currency || "").toLowerCase() !== (intent.currency || "").toLowerCase()) {
return ["mismatch", "currency does not match"];
}
if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
return ["mismatch", "amount does not match"];
}
return ["fix", "delayed method succeeded, order never caught up"];
}
Finish the order and save the PaymentIntent id
When the action is fix, set the order to Processing, save the charge as the transaction id, and also write the PaymentIntent id into _stripe_intent_id meta if it is not already there. That meta field is what lets the reconciler, and any other script, find this order again later without a search.
def mark_processing(order_id, intent):
charge_id = intent.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={
"status": "processing",
"transaction_id": charge_id,
"meta_data": [{"key": "_stripe_intent_id", "value": intent["id"]}],
},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Matched to Stripe PaymentIntent {intent['id']} ({intent.get('payment_method_types')}), "
f"which confirmed after checkout. Marked processing by the reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
async function markProcessing(orderId, intent) {
const chargeId = intent.latest_charge || intent.id;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
status: "processing",
transaction_id: chargeId,
meta_data: [{ key: "_stripe_intent_id", value: intent.id }],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Matched to Stripe PaymentIntent ${intent.id} (${intent.payment_method_types}), ` +
`which confirmed after checkout. Marked processing by the reconciler.`,
}),
});
}
Wire it together with a dry run guard
The loop pulls each piece together. Leave DRY_RUN on for the first few runs so the script only logs what it would do. Once the report looks right for a day or two, turn it off and run it on a schedule with cron every fifteen to thirty minutes.
Always start with DRY_RUN=true. A reconciler for delayed methods writes to real orders based on a search fallback as well as metadata, so you want to see its exact plan, order by order, before it acts.
The full code
Here is the complete reconciler in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches an order that is already paid or closed.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Match SOFORT, Klarna, and other delayed methods that succeeded after checkout
to the WooCommerce order they belong to. Run on a schedule. Safe to run again and again.
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("match_delayed_payments")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "72"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DELAYED_METHODS = {"sofort", "klarna", "sepa_debit", "bancontact", "ideal"}
PAID_STATUSES = {"processing", "completed"}
CLOSED_STATUSES = {"cancelled", "refunded", "failed", "trash"}
def recent_succeeded_delayed(lookback_hours):
since = int(time.time()) - lookback_hours * 3600
for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
if intent.status != "succeeded":
continue
methods = set(intent.get("payment_method_types") or [])
if methods & DELAYED_METHODS:
yield intent
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def find_order_by_intent(intent_id):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"search": intent_id, "per_page": 5},
auth=AUTH, timeout=30,
)
r.raise_for_status()
matches = r.json()
return matches[0] if matches else None
def resolve_order(intent):
order_id = intent.metadata.get("order_id")
order = get_order(order_id) if order_id else None
if order is not None:
return order
return find_order_by_intent(intent["id"])
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded")
if order is None:
return ("orphan", "order not found")
if order["status"] in PAID_STATUSES:
return ("skip", "order already paid")
if order["status"] in CLOSED_STATUSES:
return ("skip", "order already closed")
if order.get("currency", "").lower() != intent.get("currency", "").lower():
return ("mismatch", "currency does not match")
if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
return ("mismatch", "amount does not match")
return ("fix", "delayed method succeeded, order never caught up")
def mark_processing(order_id, intent):
charge_id = intent.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={
"status": "processing",
"transaction_id": charge_id,
"meta_data": [{"key": "_stripe_intent_id", "value": intent["id"]}],
},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Matched to Stripe PaymentIntent {intent['id']} ({intent.get('payment_method_types')}), "
f"which confirmed after checkout. Marked processing by the reconciler."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
for intent in recent_succeeded_delayed(LOOKBACK_HOURS):
order = resolve_order(intent)
order_id = order["id"] if order else intent.metadata.get("order_id")
action, reason = decide(order, intent)
if action == "orphan":
log.warning("Intent %s has no matching order", intent.id)
continue
if action in ("skip", "mismatch"):
if action == "mismatch":
log.warning("Order %s: %s", order_id, reason)
continue
log.info("Order %s: %s. %s", order_id, reason, "would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
mark_processing(order_id, intent)
fixed += 1
log.info("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Match SOFORT, Klarna, and other delayed methods that succeeded after checkout
* to the WooCommerce order they belong to. Run on a schedule. Safe to run again and again.
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 72);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const DELAYED_METHODS = new Set(["sofort", "klarna", "sepa_debit", "bancontact", "ideal"]);
const PAID_STATUSES = new Set(["processing", "completed"]);
const CLOSED_STATUSES = new Set(["cancelled", "refunded", "failed", "trash"]);
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* recentSucceededDelayed(lookbackHours) {
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
if (intent.status !== "succeeded") continue;
const methods = intent.payment_method_types || [];
if (methods.some((m) => DELAYED_METHODS.has(m))) yield intent;
}
}
async function getOrder(orderId) {
return woo(`/orders/${orderId}`);
}
async function findOrderByIntent(intentId) {
const matches = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
return matches && matches.length ? matches[0] : null;
}
async function resolveOrder(intent) {
const orderId = intent.metadata.order_id;
const order = orderId ? await getOrder(orderId) : null;
if (order) return order;
return findOrderByIntent(intent.id);
}
function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
function decide(order, intent) {
if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
if (!order) return ["orphan", "order not found"];
if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
if (CLOSED_STATUSES.has(order.status)) return ["skip", "order already closed"];
if ((order.currency || "").toLowerCase() !== (intent.currency || "").toLowerCase()) {
return ["mismatch", "currency does not match"];
}
if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
return ["mismatch", "amount does not match"];
}
return ["fix", "delayed method succeeded, order never caught up"];
}
async function markProcessing(orderId, intent) {
const chargeId = intent.latest_charge || intent.id;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({
status: "processing",
transaction_id: chargeId,
meta_data: [{ key: "_stripe_intent_id", value: intent.id }],
}),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Matched to Stripe PaymentIntent ${intent.id} (${intent.payment_method_types}), ` +
`which confirmed after checkout. Marked processing by the reconciler.`,
}),
});
}
async function run() {
let fixed = 0;
for await (const intent of recentSucceededDelayed(LOOKBACK_HOURS)) {
const order = await resolveOrder(intent);
const orderId = order ? order.id : intent.metadata.order_id;
const [action, reason] = decide(order, intent);
if (action === "orphan") { console.warn(`Intent ${intent.id} has no matching order`); continue; }
if (action === "skip" || action === "mismatch") {
if (action === "mismatch") console.warn(`Order ${orderId}: ${reason}`);
continue;
}
console.log(`Order ${orderId}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await markProcessing(orderId, intent);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, since it decides whether a real order gets moved to Processing. Because we kept decide pure, no network and no Stripe account are needed. It just feeds in plain objects and checks the action.
from match_delayed_payments import decide
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000, "currency": "usd", "id": "pi_1"}
base.update(over)
return base
def order(**over):
base = {"status": "pending", "total": "50.00", "currency": "USD"}
base.update(over)
return base
def test_fix_when_pending_and_delayed_method_succeeded():
assert decide(order(), intent())[0] == "fix"
def test_skip_when_already_processing():
assert decide(order(status="processing"), intent())[0] == "skip"
def test_skip_when_order_cancelled():
assert decide(order(status="cancelled"), intent())[0] == "skip"
def test_mismatch_when_amount_differs():
assert decide(order(total="40.00"), intent())[0] == "mismatch"
def test_mismatch_when_currency_differs():
assert decide(order(currency="EUR"), intent())[0] == "mismatch"
def test_orphan_when_order_missing():
assert decide(None, intent())[0] == "orphan"
def test_skip_when_intent_not_yet_succeeded():
assert decide(order(), intent(status="processing"))[0] == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./match-delayed-payments.js";
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, currency: "usd", id: "pi_1", ...over });
const order = (over = {}) => ({ status: "pending", total: "50.00", currency: "USD", ...over });
test("fix when pending and delayed method succeeded", () => {
assert.equal(decide(order(), intent())[0], "fix");
});
test("skip when already processing", () => {
assert.equal(decide(order({ status: "processing" }), intent())[0], "skip");
});
test("skip when order cancelled", () => {
assert.equal(decide(order({ status: "cancelled" }), intent())[0], "skip");
});
test("mismatch when amount differs", () => {
assert.equal(decide(order({ total: "40.00" }), intent())[0], "mismatch");
});
test("mismatch when currency differs", () => {
assert.equal(decide(order({ currency: "EUR" }), intent())[0], "mismatch");
});
test("orphan when order missing", () => {
assert.equal(decide(null, intent())[0], "orphan");
});
test("skip when intent not yet succeeded", () => {
assert.equal(decide(order(), intent({ status: "processing" }))[0], "skip");
});
Case studies
The furniture store that shipped nothing for a day
A home goods store offered Klarna at checkout. Klarna approved and funded a batch of orders about six hours after checkout, right as the store's webhook endpoint was mid deploy and returning errors for a few minutes. Every order in that window stayed on Pending even though Klarna had already paid the store.
Running the reconciler with a 72 hour lookback found all eleven orders in one pass. Dry run listed them first, the team confirmed each amount against the Klarna dashboard, then ran it for real and every order moved to Processing with a clear note.
The order that only had a charge id, not an order id in metadata
A checkout customization stripped the order_id metadata key from the PaymentIntent for SOFORT payments specifically, so the usual match by metadata failed for that one payment method. The order still had the PaymentIntent id saved in its own meta from checkout.
Because the script falls back to searching WooCommerce orders by the PaymentIntent id when metadata is missing, it still found the exact order and fixed it without any manual digging through the Stripe dashboard.
Once this runs on a schedule, a slow confirmation from SOFORT or Klarna is no longer a support ticket. The worst case becomes a short wait, bounded by your lookback window and run frequency, before the order catches up to what Stripe already knows. Keep the amount and currency checks strict. They are what makes it safe to run unattended.
FAQ
Why does my WooCommerce order stay Pending when SOFORT or Klarna eventually confirms the payment?
SOFORT and Klarna are delayed notification methods. The buyer leaves checkout before the bank or lender confirms the payment, so the order is created as Pending and the PaymentIntent only reaches succeeded minutes or hours later. If the follow up webhook is missed, filtered, or arrives while the site cannot process it, the order never learns the payment actually finished.
Is it safe to match a late Stripe charge to an order automatically?
Yes, when the script only matches a PaymentIntent whose status is succeeded to the exact order named in its metadata, skips orders that are already paid, cancelled, or refunded, and only acts when the amount and currency match. Run it in dry run mode first so you can read the plan before anything is written.
How often should I run the reconciler for delayed payment methods?
Every fifteen to thirty minutes is enough, since SOFORT and Klarna confirmations usually land within a few hours, not seconds. Widen the lookback window to two or three days so a payment that takes a full business day to clear is still caught.
Related field notes
Citations
On the problem:
- Stripe docs: payment methods without instant confirmation, including SOFORT, Klarna, and bank debits. docs.stripe.com/payments/payment-methods/overview
- Stripe docs: SOFORT payments and delayed notification behavior. docs.stripe.com/payments/sofort
- WooCommerce docs: Stripe order statuses and how automatic updates depend on webhooks. woocommerce.com/document/stripe
On the solution:
- Stripe docs: reconcile by listing events and objects rather than relying on the webhook alone. docs.stripe.com/webhooks/process-undelivered-events
- Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
- WooCommerce REST API: list, search, and update orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 fix your stuck orders?
If this saved you a pile of support tickets or a confused Klarna dispute, 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