Checkout Step Skipping: Skipping Payment and Shipping in Multi-Step Flows
Checkout is a state machine dressed up as a wizard. Cart → address → shipping → payment → confirm. The UI enforces order. The API often does not.
If POST /checkout/confirm only checks that a session has a cart ID, you can land a fulfilled order without ever touching payment.
Sketch the real graph
Proxy a normal purchase end to end. List every mutation:
POST /cart/itemsPUT /checkout/shippingPOST /checkout/payment-intentPOST /checkout/complete
Note which responses return a checkoutId, step, or status field. Those values are the real gate, not the "Next" button.
I've seen SPAs keep currentStep in React state while the backend stores READY_FOR_PAYMENT. The browser refuses to render the confirm page. Repeater does not care.
GraphQL checkouts deserve the same map. A single completeCheckout mutation may accept an optional paymentMethodId that the web client always sends. Mobile or an older storefront build might omit it. Diff the schemas and the actual traffic.
Skip forward, then skip back
With a filled cart, call the final complete endpoint before payment. Vary:
- Missing
paymentIntentId - A stale intent from a cancelled attempt
status=paidforged in JSON when the client normally sends onlycheckoutId
If complete succeeds, open the order detail and the confirmation email. An unpaid order that shows processing or triggers warehouse events is impact. A 200 that creates a pending_payment row you cannot advance is usually noise.
Also try jumping backward. Finish payment, then re-hit PUT /shipping with a cheaper method or a digital-delivery flag. Some stacks recalculate totals only on the forward path.
Digital goods are a sharp edge: if shipping is optional in the data model, skipping the shipping step may be intended. Focus on payment, tax, age gates, and terms acceptance when the SKU never needs an address.
What "paid" actually means
Ask whether the server verified a webhook from the processor, or trusted a query param like ?paid=1 after a redirect. Client-side success pages are theater. Replay the redirect URL without completing 3-DS. If the order flips to paid, that is the bug—not the pretty thank-you HTML.
Use a low-value SKU and an account you own. Never complete a real card charge you cannot reverse unless the program's policy allows it; sandbox keys and test mode are cleaner when available.
Guest checkout adds another twist. Complete as guest, copy the checkoutId, then finish from a second browser with no cookies. If the ID alone authorizes confirm, you skipped more than a step—you skipped session binding.
Report shape that survives triage
Write the intended step order, the request that skipped a step, and the business consequence (unpaid goods, free shipping upgrade, tax wiped). Attach the order ID, timestamps, and a screenshot of the merchant dashboard or order API showing payment state.
Call out whether fulfillment emails or warehouse webhooks already fired. Triage grades "order row exists unpaid" lower than "pick ticket created."
Recommend server-side transitions with an explicit allowlist (CART → SHIPPING → PAYMENT → COMPLETE), verifying payment with the provider API before fulfillment, and rejecting complete calls when required fields or prior steps are absent.