Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 003 / 100

Payment authorization vs capture: lessons from production

Two steps that are often confused.

Expona Admin7 min read

The confusion that costs teams days of debugging

Most payment systems separate the moment you check that a card is valid from the moment you actually take the money. This is the split between payment authorization and capture, and it's one of the most misunderstood concepts in payment engineering. Get it wrong in production, and you'll either hold customer funds indefinitely, lose orders because funds expired, or charge people twice. Payment authorization vs capture: lessons from production starts with understanding that these are two distinct operations with very different timings and implications.

Authorization — Checking the card without moving money

An authorization is a promise. When you authorize a payment, you're asking the customer's bank: "Does this card exist? Does it have funds?" The bank checks, holds the amount temporarily, and tells you yes or no. At this point, no money has actually moved from the customer's account to yours.

Think of it like a restaurant checking your credit card before you eat. They swipe it, confirm it's valid, but they don't charge you yet. The hold appears on your statement, but you haven't been debited. The bank typically holds authorization amounts for 3–7 days by default, though this varies by processor and card issuer.

In Stripe, an authorization happens automatically when you create a PaymentIntent with the default capture_method: 'automatic'. The funds are immediately captured, so authorization and capture happen back-to-back so quickly you might think they're the same step. But they're not, and distinguishing them matters when you need control.

A real scenario: you're building an e-commerce platform. A customer adds items to their cart and checks out. The PaymentIntent creates an authorization on their card. This is fast—typically a 1-2 second API call to the card network. At this point, you know you can take the money. You haven't yet, but the cardholder can't spend that amount elsewhere.

Capture — Taking the held funds

Capture is when actual money moves. Once a PaymentIntent is authorized, you trigger a capture, and the funds settle into your bank account (usually within 1-2 business days, depending on your processor and account setup). Before capture, the money is just a hold—the customer sees it on their statement, but it's not deducted yet.

Why separate authorization from capture? Because real-world fulfillment doesn't happen instantly. You might authorize payment when an order is placed, but you don't want to capture it until you've picked and shipped the item. If the item sells out, or you discover a stock discrepancy, you can void the authorization and the customer is never charged.

Here's the key: a captured payment is final (short of a refund or chargeback). An authorized-but-uncaptured payment can be voided with zero impact to the customer. That's a powerful difference in operational flexibility.

In Stripe, a capture happens automatically if you leave capture_method at its default. But when you explicitly set capture_method: 'manual', the PaymentIntent authorizes but doesn't capture. You then call capture() later, when you're ready.

Manual capture — Capture when you ship

The most common production use case for delayed capture is the fulfillment model: authorize on order, capture on ship.

A customer buys a product at 2 PM. You create a PaymentIntent with capture_method: 'manual'. The payment is authorized immediately, and the cardholder's bank holds the funds. Your fulfillment team picks the item, packs it, and hands it to the carrier by 4 PM. Once the carrier confirms pickup, your system captures the payment. Money moves.

Why this flow? Because if the item goes out of stock between order and fulfillment, you can void the authorization without charging the customer. Or if the customer cancels within that window, you cancel the authorization. The customer never sees a charge on their bank statement.

Here's how you set it up:

typescript
const paymentIntent = await stripe.paymentIntents.create({
  amount: 4999,
  currency: 'usd',
  payment_method: 'pm_card_visa',
  confirm: true,
  capture_method: 'manual',
});

console.log(paymentIntent.status); // 'requires_capture'

The status is requires_capture. The payment is authorized but not captured. Now, when your fulfillment system signals the item is shipped, you capture:

typescript
const captured = await stripe.paymentIntents.capture(paymentIntent.id);

console.log(captured.status); // 'succeeded'

The status flips to succeeded, and money moves to your account.

If the item can't be fulfilled, you cancel instead:

typescript
const cancelled = await stripe.paymentIntents.cancel(paymentIntent.id);

console.log(cancelled.status); // 'canceled'

The customer sees no charge. Clean.

Another real scenario: a SaaS platform offers a 14-day free trial. You authorize the customer's payment method on day 1 to confirm it's valid, but you don't capture until day 15 when the trial ends. If the customer cancels on day 10, you void the authorization and they're never charged. They trust you more because you didn't charge them for a service they didn't use.

Expiry — Authorizations don't live forever

This is where many teams get burned in production. An uncaptured authorization doesn't last indefinitely. Most card networks (Visa, Mastercard, American Express) let issuers hold an authorization for 7 days by default. After that, the hold drops off the customer's statement and the authorization is effectively void. You can't capture it anymore.

Scenario: A B2B SaaS platform authorizes a customer's payment on day 1 of month 1. The billing team is backed up and doesn't get around to capturing until day 10. The authorization has already expired. The capture call fails with an error like expired_token or card_error. The customer never got charged, and now it's an awkward conversation to ask them to pay again.

The fix is straightforward: capture quickly, or re-authorize if you need to wait. Stripe lets you set a statement_descriptor on the PaymentIntent so the authorization appears clearly on the customer's statement with your business name. This reduces support inquiries and chargebacks when customers see an unexplained hold.

If you do need to wait longer than a few days (rare but possible in B2B scenarios), authorize, and if it ages beyond 5 days without capture, cancel it and create a fresh PaymentIntent:

typescript
const checkAndRecreateIfNeeded = async (piId, maxAgeHours = 120) => {
  const pi = await stripe.paymentIntents.retrieve(piId);
  const ageHours = (Date.now() - pi.created * 1000) / (1000 * 60 * 60);
  
  if (ageHours > maxAgeHours && pi.status === 'requires_capture') {
    await stripe.paymentIntents.cancel(piId);
    
    const newPi = await stripe.paymentIntents.create({
      amount: pi.amount,
      currency: pi.currency,
      payment_method: pi.payment_method,
      confirm: true,
      capture_method: 'manual',
    });
    
    return newPi.id;
  }
  
  return piId;
};

This is defensive programming. You check the age, cancel if it's stale, and issue a fresh authorization. The customer's old hold drops off, and a new one appears. In most cases, they won't even notice.

Common mistakes

  • Forgetting that manual capture is opt-in. Many teams assume they have days to capture, but Stripe defaults to immediate capture. Always set capture_method: 'manual' if you need control, and document it clearly in code comments.
  • Not monitoring authorization age. Build a job that alerts if an authorization is older than, say, 5 days. Better to fail fast and re-auth than discover the expiry when you try to capture.
  • Assuming the customer sees the charge immediately. When you authorize but don't capture, the hold appears on their statement but they're not debited yet. If support calls in confused, you'll explain it many times. Mention it clearly in your order confirmation email.
  • Mixing authorization and capture error handling. An authorization failure is different from a capture failure. Authorization fails because the card is invalid or the cardholder denied it. Capture fails because the authorization expired or the payment method was removed. Handle them differently.
  • Not testing the manual capture flow end-to-end. Test the happy path (auth → fulfill → capture), the cancellation path (auth → cancel), and the expiry path (auth → wait 7 days → capture fails). Use Stripe's test cards and the card_always_fails test card to simulate edge cases.

Key takeaways

  • Authorization checks the card and holds funds; capture actually moves the money. They are two separate operations with different timings and outcomes.
  • Use manual capture (capture_method: 'manual') when you need to fulfill before charging, such as e-commerce, free trials, or marketplace hold patterns.
  • Authorizations expire after about 7 days by default. Don't assume you have unlimited time to capture; monitor age and refresh if needed.
  • A voided authorization never charges the customer and doesn't appear as a debit on their statement. Leverage this for better customer trust in cancellation flows.
  • Test both the happy path and edge cases (cancellation, expiry, re-auth) in your test suite. Production payment logic fails in ways that are hard to reproduce if you don't exercise the full state machine.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles