Most checkout bugs do not come from the payment provider. They come from our own app logic.

The big mistake is treating checkout like one button press:

  • click pay
  • get success callback
  • unlock product

In real systems, payment confirmation and access provisioning are separate events. If you merge them into one step, users get charged but blocked. That is the worst trust break you can create.

Payment confirmation and entitlement activation are separate events and should be handled separately in code.

Step 1: Model checkout as states, not boolean flags

If your code uses isPaying = true and isSuccess = false, it will collapse under real-world failures.

type CheckoutState =
  | { kind: 'idle' }
  | { kind: 'creatingOrder' }
  | { kind: 'awaitingPayment'; orderId: string }
  | { kind: 'verifyingAccess'; orderId: string }
  | { kind: 'done' }
  | { kind: 'error'; message: string };

This is the backbone. Every UI action should move between these explicit states.

Step 2: Separate order creation from payment confirmation

async function createOrder(planId: string): Promise<{ orderId: string }> {
  const res = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ planId }),
  });
  if (!res.ok) throw new Error('order creation failed');
  return res.json();
}

Order creation should happen before redirecting to payment. Save orderId locally so refresh does not lose progress.

Step 3: Verify access after payment callback

Payment callback means "money event happened." It does not always mean your entitlement table is already updated.

async function waitForEntitlement(orderId: string): Promise<boolean> {
  for (let i = 0; i < 6; i += 1) {
    const res = await fetch('/api/entitlements/' + orderId);
    if (!res.ok) throw new Error('entitlement check failed');
    const data = await res.json();
    if (data.active === true) return true;
    await new Promise((r) => setTimeout(r, 1200));
  }
  return false;
}

This retry window handles propagation delay without confusing the user.

Step 4: Build UI language for each failure mode

Do not show one generic error for everything.

  • payment failed -> "Your payment was not completed"
  • verification timed out -> "Payment received, still confirming access"
  • network error -> "Connection issue, tap retry"

Specific language reduces support tickets dramatically.

Step 5: Recover after refresh

If users refresh during checkout, resume from stored orderId and re-run verification. Never force them to pay again just because the browser reloaded.