Pre-authorization holds for Payments & Stripe developers
When a customer books a rental car or reserves a hotel room, you need confidence that their payment method will cover the final cost before you commit resources. Pre-authorization holds solve this by reserving funds upfront without immediately charging the card. For payments & Stripe developers, mastering hold management is essential to minimize chargebacks, reduce fraud, and create smooth user experiences across bookings and rentals.
Why holds matter in rental and booking workflows
Pre-authorization holds bridge the gap between reservation and settlement. A customer books a week-long car rental with an estimated cost of $800, but damage waivers, mileage overages, or late returns could change that figure. Rather than charge the card immediately, you authorize $800 to verify funds are available. The hold doesn't debit the account—it signals to the card issuer that this amount is unavailable for other transactions. This protects you from booking a car to someone whose card gets declined at checkout, and it protects customers from unexpected charges.
Rental companies, vacation rental platforms, and event ticketing systems all rely on this pattern. The key is managing the lifecycle: authorize, adjust if needed, capture the final amount, and release any unused hold quickly.
Authorize upfront — Hold the expected amount
The first step is to create an authorization hold in Stripe. This is a PaymentIntent with a confirm parameter set to confirm the payment immediately, or a SetupIntent if you want to store the payment method for later. For most rental and booking scenarios, you'll use a PaymentIntent with capture_method set to manual. This tells Stripe to verify the card but hold funds without capturing them.
Here's a practical example: a customer books a 3-night stay at a vacation rental property with an estimated total of $1,200.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createAuthorizationHold(customerId: string, estimatedAmount: number) {
const paymentIntent = await stripe.paymentIntents.create({
customer: customerId,
amount: estimatedAmount * 100, // amount in cents
currency: 'usd',
capture_method: 'manual',
statement_descriptor: 'ACME Vacation Rental',
metadata: {
bookingId: 'booking_12345',
propertyId: 'prop_789',
checkoutDate: '2025-03-15',
},
});
return paymentIntent;
}
// Usage
const intent = await createAuthorizationHold('cus_abc123', 1200);
console.log(`Authorization hold created: ${intent.id}, status: ${intent.status}`);
The capture_method: 'manual' is critical. It creates an authorization without immediately capturing funds. The status will be requires_confirmation initially, then move to succeeded once confirmed via your frontend with stripe.confirmCardPayment() or similar. After confirmation, the status becomes requires_capture—funds are held but not yet charged.
Store the PaymentIntent ID and the authorization amount in your booking record. You'll need this ID to adjust, capture, or cancel the hold later.
Adjust — Incremental authorization where supported
Real-world bookings rarely end at the estimated amount. A guest may add insurance, incur late fees, or drop a service. You have two approaches: wait until final capture, or use incremental authorizations if the card network supports them.
Stripe supports incremental authorization for card payments (Visa, Mastercard, Amex). If a guest adds a $150 damage waiver midway through their stay, you can authorize an additional $150 rather than cancel and re-authorize the full new total. This is less disruptive to the customer.
async function incrementalAuthorization(paymentIntentId: string, additionalAmount: number) {
try {
const updated = await stripe.paymentIntents.update(paymentIntentId, {
amount_details: {
tip: { amount: additionalAmount * 100 },
},
});
console.log(`Amount increased from ${updated.amount} to ${updated.amount}`);
return updated;
} catch (error) {
console.error('Incremental authorization failed:', error);
throw error;
}
}
// Usage: guest adds $150 service
const updatedIntent = await incrementalAuthorization('pi_1234567890', 150);
Not all card types or acquirers support incremental authorization. Visa and Mastercard generally do, but AmEx and some regional issuers may decline. Always wrap this in error handling. If it fails, fall back to canceling the original hold and creating a new authorization for the full updated amount.
For recurring or subscription-like charges (e.g., nightly rate increases), you may also capture the original authorization for the nightly rate, then create a new hold for additional charges—though this adds complexity and friction. Incremental auth is cleaner when available.
Capture the final amount — Capture less than authorized if needed
When the booking concludes—car is returned, guest checks out, event occurs—you capture the final amount. If the actual cost is less than the authorized amount, you capture only what's owed. If it's more (and within a reasonable threshold above the authorization), Stripe allows you to capture up to 115% of the authorized amount on some networks without explicit re-authorization.
Suppose the vacation rental guest's final bill is $1,180 (they returned early). You authorized $1,200, but capture only $1,180. The remaining $20 is automatically released after 7–10 days (varies by issuer).
async function capturePayment(paymentIntentId: string, finalAmount: number) {
const captured = await stripe.paymentIntents.capture(paymentIntentId, {
amount_to_capture: finalAmount * 100, // in cents
});
console.log(
`Captured ${captured.amount_received / 100} from authorization of ${captured.amount / 100}`
);
return captured;
}
// Usage
const captured = await capturePayment('pi_1234567890', 1180);
After capture, the PaymentIntent.status becomes succeeded. The card is now charged. Any difference between authorized and captured amounts enters a release queue.
Always capture as soon as you have a final amount. Leaving funds on hold for weeks annoys customers and increases chargeback risk if they dispute the reservation, because from their perspective, the full authorized amount still shows pending.
Release — Cancel unused holds promptly
If a booking is canceled before fulfillment, you must cancel the authorization hold to release reserved funds. This is a customer service imperative—many guests check their bank account and see a hold as a charge. Canceled reservations must release holds immediately.
In Stripe, canceling a PaymentIntent with capture_method: 'manual' and status requires_capture voids the authorization.
async function releaseAuthorizationHold(paymentIntentId: string, reason: string) {
const canceled = await stripe.paymentIntents.cancel(paymentIntentId, {
cancellation_reason: reason, // e.g., 'requested_by_customer', 'abandoned'
});
console.log(
`Authorization hold canceled. PaymentIntent ${canceled.id} status: ${canceled.status}`
);
return canceled;
}
// Usage: guest cancels booking
await releaseAuthorizationHold('pi_1234567890', 'requested_by_customer');
After cancellation, the status becomes canceled. Stripe sends a reversal to the card issuer, and the hold typically disappears from the customer's account within 24–48 hours. Some issuers are slower; communicate to your users that it may take up to 5 business days.
Automate this. Set up a background job that scans for canceled bookings and releases any authorization holds older than a threshold (e.g., 1 hour for instant cancellations, or same-day for policy cancellations). Every day a hold remains unreleased increases customer friction.
Common mistakes
Forgetting to set capture_method: 'manual': If you omit this, Stripe captures immediately, defeating the purpose of an authorization hold. The full amount is charged to the card and appears as a settled transaction, not a pending hold.
Leaving holds unreleased on canceled bookings: This is a leading cause of chargebacks and negative reviews. Implement a cancellation workflow that immediately calls paymentIntents.cancel() or uses webhook events to detect cancellations and release holds asynchronously.
Attempting to capture more than 115% of authorized amount: Stripe will reject capture requests that exceed 115% of the original authorization (network rules). If you need to charge significantly more—due to damage or extended stay—create a new PaymentIntent for the additional charge rather than trying to force it through the original hold.
Not idempotent release calls: If a booking is canceled and your service crashes mid-release, you might attempt release twice. Always include an idempotency key when calling cancel(), so a retry doesn't fail or create duplicate reversals.
// Idempotent release
await releaseAuthorizationHold('pi_1234567890', 'requested_by_customer');
// Internally, use a unique idempotency key:
const cancelResponse = await stripe.paymentIntents.cancel(
paymentIntentId,
{ cancellation_reason: reason },
{ idempotencyKey: `cancel-${bookingId}-${Date.now()}` }
);
Not storing authorization metadata: Link every authorization to your booking record. Store the PaymentIntent ID, authorized amount, and timestamp. This makes debugging and reconciliation far easier if issues arise.
Key takeaways
- Use
capture_method: 'manual'onPaymentIntentcreation to authorize without immediately capturing funds. - Store the
PaymentIntentID and authorized amount in your booking database for later reference. - Leverage incremental authorization to add charges (e.g., additional services, fees) without full re-authorization, if supported by the card network.
- Capture the final amount promptly once the booking is fulfilled or completed; capture less than authorized if appropriate.
- Release unused holds immediately upon cancellation to maintain customer trust and reduce chargebacks.
- Use idempotency keys for release and capture calls to handle retries safely.
- Monitor hold lifecycle with Stripe webhooks to catch edge cases and automate cleanup.






Comments
Loading comments…