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:
- Authorise: the customer's bank confirms the money is there and reserves it.
- 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.