Mojo Layers

Stripe Manual Capture: How to Hold a Card Payment and Charge It Later

How to place a card hold with Stripe, capture or release it later, and avoid expired holds and double charges. Node examples for Checkout and PaymentIntents.

By Joe Shenton7 min readBackendPayments

Plenty of businesses can't charge a customer the moment they click "Pay". A print shop needs to check the file first. A hire company wants a bond it may never take. A custom-order business needs to confirm stock or a final weight before it knows the real price.

Stripe handles this with manual capture: you authorise the card now (the money is reserved on the customer's card) and decide later whether to take it, take less of it, or release it. Our own 3D print quotes work this way: the card is held at checkout and only charged once we've checked the files.

This guide covers how manual capture works, the time limits that catch people out, and the code you need on both Stripe Checkout and the PaymentIntents API.

Authorise vs capture, in plain English

A normal card payment does two things at once:

  1. Authorise: the customer's bank confirms the money is there and reserves it.
  2. Capture: you actually claim the money and it starts moving to your account.

With manual capture you split those steps. After step 1 the customer sees a pending transaction on their card, and the PaymentIntent sits in the requires_capture status. Nothing has been paid out yet. From there you can:

  • Capture the full amount when the job is confirmed.
  • Capture a smaller amount if the final price came in lower. The rest is released automatically.
  • Cancel the PaymentIntent to release the whole hold.

One thing to warn customers about: Stripe notes that some card statements don't clearly distinguish holds from completed payments, so say "your card is held, not charged" in plain words at checkout and in the confirmation email.

The time limit that catches everyone

A hold doesn't last forever. If you don't capture in time, the authorisation expires, the funds go back to the customer and the PaymentIntent moves to canceled. You can't capture it after that; you'd have to ask the customer to pay again.

Stripe's authorisation validity windows for online card payments where the customer is present at checkout:

Payment method How long you have to capture
Visa, Mastercard, Amex (online, customer-initiated) 7 days
Visa (merchant-initiated, e.g. charging a saved card) about 4 days 18 hours
Card-present (Stripe Terminal), Mastercard/Amex 2 days
Afterpay / Clearpay 13 days (first instalment is taken up front)
PayPal 10 days, auto-extended by Stripe to 20

So for most Australian online businesses the working rule is: plan to capture within 7 days, and build for 4. If your process sometimes takes longer, Stripe offers extended authorisations of up to 30 days for eligible card payments, but don't assume your account or every card qualifies. Check the capture_before timestamp on the charge's card details rather than hard-coding a number of days.

Also note that not every payment method supports holds. Cards, Afterpay, Klarna and PayPal do; many bank-debit methods don't, so a hold flow usually means "cards (and maybe Afterpay) only".

Option 1: Stripe Checkout with a hold

If you use Stripe's hosted Checkout page, it's one extra parameter: payment_intent_data.capture_method. This is the Stripe Node library:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const session = await stripe.checkout.sessions.create(
  {
    mode: "payment",
    line_items: [
      {
        quantity: 1,
        price_data: {
          currency: "aud",
          unit_amount: 8450, // $84.50 in cents
          product_data: { name: "Order ML-1042" },
        },
      },
    ],
    payment_intent_data: {
      capture_method: "manual",
      metadata: { orderRef: "ML-1042" },
    },
    success_url: "https://example.com/held?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/cart",
  },
  { idempotencyKey: `checkout-ML-1042` }
);

When the customer finishes, the Checkout Session has a payment_intent ID. That's what you capture or cancel later, not the session ID.

Option 2: PaymentIntents directly

If you're building your own payment form with Stripe Elements, set capture_method on the PaymentIntent. If you want holds on cards but normal payments for other methods, set it per method instead:

const intent = await stripe.paymentIntents.create({
  amount: 8450,
  currency: "aud",
  automatic_payment_methods: { enabled: true },
  // Hold cards only; other payment methods charge immediately
  payment_method_options: { card: { capture_method: "manual" } },
  metadata: { orderRef: "ML-1042" },
});

That per-method form matters, because Stripe won't let a single PaymentIntent use top-level capture_method: "manual" together with a payment method that can't do holds.

Capturing, partially capturing and releasing

Once you've decided, it's one call either way:

// Take the full held amount
await stripe.paymentIntents.capture(intentId, {}, {
  idempotencyKey: `capture-${intentId}`,
});

// Take less than you held ($62.00 of $84.50); the rest is released
await stripe.paymentIntents.capture(intentId, { amount_to_capture: 6200 }, {
  idempotencyKey: `capture-${intentId}`,
});

// Release the whole hold
await stripe.paymentIntents.cancel(intentId, {
  cancellation_reason: "requested_by_customer",
});

Two rules from Stripe's docs to design around:

  • You usually only get one capture. If you partially capture, you can't come back later for the difference. If the price might go up, hold the maximum and capture down, or save the card and create a second payment.
  • Capturing more than you held (overcapture) is only possible for certain online card payments. Don't build a flow that depends on it unless you've confirmed it's enabled for you.

Don't trust the redirect: confirm with Stripe

The success URL is only a hint. Customers close tabs, mobile browsers drop redirects, and anyone can type a URL. Before you treat an order as "held", fetch the PaymentIntent and check its status:

const session = await stripe.checkout.sessions.retrieve(sessionId);
const intent = await stripe.paymentIntents.retrieve(session.payment_intent);

if (intent.status === "requires_capture") {
  // Card is held for intent.amount_capturable cents: mark the order as held
} else if (intent.status === "succeeded") {
  // Already captured
} else {
  // Not held (abandoned, failed or cancelled): don't fulfil
}

For a reliable backstop, listen for webhooks as well. When a customer completes a manual-capture payment, Stripe sends payment_intent.amount_capturable_updated; when a hold is cancelled or expires you'll see payment_intent.canceled. Always verify the signature. On Cloudflare Workers or Pages Functions, use the async variant with the Web Crypto provider:

const event = await stripe.webhooks.constructEventAsync(
  await request.text(),               // raw body, not parsed JSON
  request.headers.get("stripe-signature"),
  env.STRIPE_WEBHOOK_SECRET,
  undefined,
  Stripe.createSubtleCryptoProvider()
);

if (event.type === "payment_intent.amount_capturable_updated") {
  await markOrderHeld(event.data.object.metadata.orderRef);
}

Webhooks can arrive more than once and out of order, so make markOrderHeld safe to run twice: check the order's current state before changing it or sending emails.

Idempotency: stop double captures and duplicate sessions

Network calls fail halfway. If your server times out while creating a Checkout Session and retries, you can end up with two. Stripe's API accepts an idempotency key on every POST: retries with the same key return the original result instead of doing the work twice.

A few practical points from the docs:

  • Keys can be up to 255 characters. Use something tied to the action, like capture-<intentId>, or a random UUID you store with the order.
  • Stripe can prune keys once they're 24 hours old, so idempotency protects retries, not a capture you run again next week.
  • Reusing a key with different parameters returns an error, which is what you want.
  • Don't put email addresses or other personal details in keys.

A checklist before you ship holds

Check Why
Checkout says "held, not charged" Fewer confused customers and disputes
Order state comes from Stripe, not the redirect Stops fake or abandoned orders being fulfilled
Webhook signature verified, handler is idempotent Duplicates and replays do nothing
A daily job flags holds older than ~4 days Nothing quietly expires
Capture and cancel actions are authenticated Only staff can take money or release it
Partial-capture rules explained to staff One capture per hold, usually
Test mode run through with Stripe's test cards Every branch exercised before going live

When manual capture is the wrong tool

Holds suit short, uncertain waits. If you regularly need more than a week, or you're charging the same customer repeatedly, save the payment method instead (a SetupIntent, or setup_future_usage on the first payment) and create a fresh payment when the work is done. If you need a deposit now and the balance later, two separate payments are clearer for everyone than one long hold.

If you'd rather not wire this up yourself, payments, webhooks and admin dashboards are part of our MVP / Web App builds, and we're happy to look at an existing integration too.

Want card holds, webhooks or a payment flow built properly the first time? Tell us what you're building.