DEV Community

Cover image for The Authorize/Cancel Race: When a Customer Pays for an Order That No Longer Exists
Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

The Authorize/Cancel Race: When a Customer Pays for an Order That No Longer Exists

Cancelling an order in your database does nothing at Stripe. The client_secret you shipped to the payment sheet is a live capability — it keeps accepting confirmation after your order row says cancelled, and the authorization webhook will happily record money against an order that no longer exists. The race runs in both directions: the customer can pay after you cancel, and you can cancel after the customer pays, with the second gap measured at two seconds in our production logs. The fix is symmetrical: the cancel path must kill the intent at Stripe, and the confirm path must check the parent order before accepting the money.

This is Part 3 of the Stripe in Production series. It assumes the manual-capture flow from Part 1 — authorize at checkout, capture on fulfilment, void on cancellation.

Two races, one root

Both incidents that taught us this were mundane. No load, no outage — just ordinary latency between a phone and two servers.

RACE A — cancel, then pay (the stale sheet)
T+0s    customer opens payment sheet, closes the app
T+15m   sweeper expires the session, cancels the order
T+16m   customer reopens the app; the sheet is still on screen
        client_secret still valid at Stripe => hold authorized
        => money locked against an order that died a minute ago

RACE B — pay, then cancel (two seconds)
T+0.0s  customer confirms in the sheet
T+1.5s  authorization webhook lands; hold recorded
T+3.5s  order auto-cancels (no availability)
        => release path runs immediately — into whatever
           state the webhook just wrote

One root: two systems each believed their own state was the whole truth. Your database is not Stripe's state, and Stripe is not yours. Every fix below is one side refusing to trust the other blindly.

A client_secret is a capability, not a record

Treat every client_secret you ship as an outstanding key to your customer's card. It lives on the customer's device, outside your transaction boundaries, and it does not check your order table before working. Deleting the order, cancelling it, even deleting the session row — none of it revokes the key. Only two things do: explicit cancellation of the PaymentIntent, or its natural expiry. Once you see it that way, the rule writes itself: whoever invalidates the order must also revoke the key.

Fix the cancel side: kill the intent when the session dies

// BAD: local-only decline — the client_secret out there still works
public function decline(string $provider, string $orderRef): void
{
    $session = CheckoutSession::where('provider', $provider)
        ->where('merchant_order_id', $orderRef)->first();

    $session?->update(['status' => CheckoutSessionStatus::Declined]);
    // ... cancel order, release hold — Stripe never hears about any of it
}
// GOOD: revoke the key at Stripe FIRST, then record the decline
public function decline(string $provider, string $orderRef, string $reason): void
{
    $session = CheckoutSession::where('provider', $provider)
        ->where('merchant_order_id', $orderRef)->first();

    if (! $session || $session->status === CheckoutSessionStatus::Declined) {
        return; // idempotent — sweeper and webhook may both call this
    }

    // HTTP deliberately OUTSIDE the transaction below, and best-effort:
    // an unreachable Stripe must never block the order cancellation.
    // Ordered FIRST so a failure that rolls back the transaction leaves
    // the session pending — and the next sweep retries the cancel.
    // After the commit, the session is Declined and never revisited.
    if ($provider === 'stripe' && str_starts_with((string) $session->provider_payment_id, 'pi_')) {
        try {
            $this->stripe->paymentIntents->cancel($session->provider_payment_id);
        } catch (\Throwable $e) {
            Log::warning('Could not cancel intent on decline', [
                'intent' => $session->provider_payment_id,
                'error'  => $e->getMessage(),
            ]);
        }
    }

    DB::transaction(function () use ($session, $reason) {
        $session->update(['status' => CheckoutSessionStatus::Declined]);
        $this->holds->release($session->hold);
        $this->orders->cancel($session->order, $reason);
    });
}

The ordering carries the retry semantics, so it is not style — cancel-at-Stripe first means a crashed decline self-heals on the next sweep, while the reverse order can mark the session dead with the key still live. After this shipped, a reopened stale sheet fails at confirmation instead of taking the customer's money. That failure is the feature.

Fix the confirm side: check the parent before accepting money

Race B needs the mirror-image guard. Your authorization webhook handler was written imagining a live order — but it can fire after cancellation, and recording a hold nobody will ever settle creates the exact stranded-money problem from Part 1.

// GOOD: the webhook confirms into the world as it is, not as it was
public function confirm(string $provider, string $orderRef): void
{
    $session = CheckoutSession::where('provider', $provider)
        ->where('merchant_order_id', $orderRef)->firstOrFail();

    if ($session->status === CheckoutSessionStatus::Authorized) {
        return; // idempotent — webhooks redeliver
    }

    $order = $session->order;

    // The order died while the customer was typing card details.
    // Accepting the authorization would strand the hold — refuse it,
    // and release the money we were just handed.
    if ($order->status->isTerminal()) {
        try {
            $this->stripe->paymentIntents->cancel($session->provider_payment_id);
        } catch (\Throwable $e) {
            Log::critical('Late authorization on dead order — cancel failed', [
                'intent' => $session->provider_payment_id,
                'order'  => $order->id,
            ]);
        }
        $session->update(['status' => CheckoutSessionStatus::Declined]);
        return;
    }

    // Normal path: record the authorization, advance the order.
    // ...
}

Note the log level on the failure branch: a late authorization on a dead order that you also failed to cancel is precisely the case your reconciliation must page a human about.

Why manual capture saved us

Both races happened with capture_method: manual, so the exposure was a temporary hold — released in days even if everything else failed — rather than a captured charge needing an apologetic refund. That is worth stating as a design principle: for any order that can be cancelled after payment begins, authorize-then-capture bounds your worst case. Automatic capture in the same races takes real money and turns an engineering bug into a support incident. Still, "it expires in a week" is not a fix; a customer watching held funds for days does not care about your capture method.

The backstop you already have

Every guard above can fail — Stripe unreachable on both attempts, a crash between webhook and handler. This is what the hourly requires_capture reconciliation from Part 1 is for: any hold older than an hour whose local record claims finished is a bug being found the same day instead of at auth expiry. The races make it necessary; the reconciliation makes them survivable.

The checklist

  • Treat every shipped client_secret as a live capability until explicitly cancelled
  • Cancel the PaymentIntent at Stripe when a session dies — before recording the decline, best-effort, retried by the sweep
  • Make decline and confirm both idempotent; sweeper and webhooks will overlap
  • In the authorization handler, check the parent order's state; void immediately on terminal orders
  • Use manual capture wherever cancellation can race payment
  • Keep the hourly reconciliation running — it catches what the guards miss

Next in this series

Part 4 goes deeper into the hold itself: manual capture in production — authorization buffers over estimates, split wallet-and-card payments where the card portion dips under Stripe's minimum, and the seven-day expiry as a deadline you design against. [LINK WHEN LIVE: /en/stripe-manual-capture-holds-split-payments]

Before then, one cheap audit: grep your cancellation paths for a Stripe cancel call. If cancelling an order only touches your own tables, every client_secret you have ever shipped is still out there, and Race A is not a possibility — it is a schedule.

I post each part natively on LinkedIn with the war story that didn't fit — follow there for Part 4, or tell me about the race you lost. I read all of them. Part 2, on the wallet gates that block payment sheet buttons, is here.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Payment races are where optimistic product flows meet hard system boundaries. I like modeling these as state machines first: which transitions are legal, which are idempotent, and what compensation happens when external payment state wins.