How to Add a Booking Deposit and Cancellation Fee
A concrete state machine for booking deposits and no-show fees, built on Stripe's manual capture, plus the exact-cutoff cancellation edge case most tutorials skip.
Here is how to add a booking deposit and cancellation fee to an AI-built app without it turning into a spreadsheet of who-owes-what: three things need to work in sync. A hold on the customer's card the moment they book, a clock that tracks the cancellation window, and a job that acts on that clock without anyone refreshing a dashboard. The reliable way to build this is Stripe's manual-capture authorization for the deposit, plus a separate off-session charge for the no-show fee. Model the whole thing as a state machine, not a single boolean column on the bookings table, or you will get burned by the case almost every tutorial skips: the customer who cancels at the exact cutoff second.
Why a single "deposit paid" column breaks down
Most first attempts model a deposit as a boolean on the booking row, deposit_paid = true. That covers exactly one path through the flow: the customer pays, nothing else happens. It falls apart the moment you need to answer real questions. Was this deposit refunded, forfeited, or never actually captured. Did the no-show fee get charged on the original authorization, or as a new charge. A boolean cannot hold that history, and you end up reconstructing it from the Stripe dashboard during a dispute, which is the worst possible time to be doing detective work.
If you have not wired up the rest of the payments stack yet, the complete guide to building an app with AI is worth reading first, since where a deposit flow sits relative to your booking, notification, and calendar logic matters as much as the Stripe calls themselves.
Model the deposit as a state machine
Six states cover almost every real booking deposit payment flow: pending, authorized, captured, refunded, forfeited, and expired. Each one maps to a specific Stripe action, and each transition should be written as a row in an audit log or event table, not just an in-place update to the booking.
State | What it means | Stripe action |
|---|---|---|
pending | Booking created, no card hold placed yet | None |
authorized | Card hold placed, funds reserved but not moved | PaymentIntent.create with capture_method manual |
captured | Deposit converted into an actual charge | PaymentIntent.capture |
refunded | Hold released before any money moved | PaymentIntent.cancel |
forfeited | Deposit kept per policy after a late cancellation or no-show | PaymentIntent.capture, no refund issued |
expired | Authorization aged out before anyone acted on it | Stripe auto-voids the hold; your app must reconcile |
That expired row exists because it happens whether you plan for it or not. Stripe releases manual-capture authorizations automatically, typically within about seven days for most cards. If your booking window is longer than that, a wedding venue booked four months out, for example, a single authorization taken at booking time will not survive to the event. You need either a shorter authorization window placed closer to the appointment, or a scheduled job that re-authorizes as the old hold nears expiry.
Authorizing the deposit
The authorization happens when the customer confirms the booking, not when they finish typing card digits into a form. Create the PaymentIntent with manual capture, and compute the cutoff timestamp once, at booking time, rather than recalculating it later from the appointment time:
`intent = stripe.PaymentIntent.create(amount=deposit_amount_cents, currency="usd", customer=customer_id, payment_method=payment_method_id, capture_method="manual", confirm=True, metadata={"booking_id": booking.id, "kind": "deposit"})`
`booking.deposit_state = "authorized"`
`booking.stripe_payment_intent_id = intent.id`
`booking.cutoff_at = booking.start_time - cancellation_window`
Storing cutoff_at as its own column, rather than deriving it on the fly from start_time and a policy constant, matters later. Policies change. If you recompute the cutoff from a constant that has since been edited, you can silently rewrite the deadline on bookings made under the old policy.
Charging the no-show fee off-session
When a customer does not show up, you either capture the existing authorization (if it is still within its roughly seven-day life) or, if that window already closed, create a new off-session charge against the saved payment method. This is the part most no-show fee app tutorials gloss over: an off-session charge can fail with authentication_required, because some card issuers demand 3D Secure on a charge the cardholder is not present for, and you cannot force that authentication through in an unattended job.
`try:`
` intent = stripe.PaymentIntent.create(amount=fee_amount_cents, currency="usd", customer=customer_id, payment_method=saved_payment_method_id, off_session=True, confirm=True, metadata={"booking_id": booking.id, "kind": "no_show_fee"})`
`except stripe.error.CardError as e:`
` if e.code == "authentication_required":`
` booking.deposit_state = "fee_failed_requires_auth"`
` notify_customer_to_reauthorize(booking)`
That fee_failed_requires_auth state is not cosmetic. Without it, a failed off-session charge silently vanishes into your Stripe error logs while your booking record still says forfeited, and finance ends up chasing money that was never actually collected.
The cutoff-second edge case
Every cancellation policy eventually gets tested by a customer who cancels at, or within a second of, the exact cutoff. Two things go wrong if you have not designed for it. First, whose clock decides: if you compare against a client-supplied timestamp from the cancellation request, a customer with a clock running two minutes slow gets treated as early when they were actually late. Always compare against the timestamp your own server assigns when it receives the request, never one the client sends.
Second, a race condition: a scheduled sweep job that captures deposits for no-shows can fire in the same window as the customer's own cancellation request. If both processes read the booking row, see deposit_state = authorized, and act on it, you can end up both refunding and capturing the same authorization. Guard the transition with a row lock, SELECT the booking FOR UPDATE inside the transaction that changes deposit_state, so whichever process gets there first wins and the second one simply finds the state already moved and exits cleanly. For the sweep job specifically, add SKIP LOCKED to the query so a slow transaction on one booking never stalls the whole batch.
On the tie itself, pick a rule and document it: treat a cancellation timestamp equal to cutoff_at as on time, in the customer's favor, rather than late. It costs you a vanishingly small number of deposits and removes an entire category of billing disputes about whether a millisecond counted.
How to add a booking deposit and cancellation fee to an AI-built app: the schema
The columns that make the state machine actually work, beyond the booking's usual customer and scheduling fields:
deposit_amount_cents (integer, never a float)
deposit_state (enum: pending, authorized, captured, refunded, forfeited, expired, fee_failed_requires_auth)
cutoff_at (timestamp, computed once at booking time)
stripe_payment_intent_id
stripe_no_show_charge_id (nullable, set only if the fee required a separate off-session charge)
If your app also gates a paid tier behind a subscription, a subscription paywall built the same way keeps the payment-method-on-file requirement consistent across both flows, so you are not maintaining two separate ways of storing a customer's saved card.
None of this requires your own database to ever see a raw card number. Stripe tokenizes the payment method and you only ever pass around an opaque ID. The risks of storing a customer's card details wrong are exactly why that trade is never worth making, even if it feels like it would simplify the schema above.
This authorize-then-decide pattern is not unique to deposits either. Usage-based billing for the same kind of app runs into an almost identical reconcile-after-the-fact problem when a customer's usage crosses a plan threshold mid-cycle: you cannot know the final number until the period closes, so you authorize conservatively and true up afterward.
Writing the policy so it does not read as a threat
The cancellation fee stripe processes for you is only half the job. The other half is copy that states the policy plainly without sounding like a punishment. Compare the two approaches side by side.
Punitive version | Neutral version |
|---|---|
You will be charged a penalty if you cancel late. | Free cancellation up to 24 hours before your appointment. After that, or for a no-show, we charge $25 to cover the reserved slot. |
A fee applies for cancellations. | Your $50 deposit is fully refundable if you cancel by Friday at 5pm. After that it goes toward the appointment. |
Show that language at booking time, not buried in a terms page, and repeat the exact cutoff time in the confirmation email using the customer's own timezone. Most disputes over a forfeited deposit are not about the policy itself, they are about the customer genuinely not knowing when the window closed.
Frequently asked questions
Frequently asked questions
Do I need Stripe specifically to charge a cancellation fee?
No. Any processor that supports a manual-capture or hold-then-charge flow works the same way. Stripe is the most common choice for AI-built apps because its PaymentIntent API makes the authorized, captured, and canceled states explicit rather than something you have to infer.
What happens if the no-show fee charge fails?
Treat it as its own state rather than a silent failure. If the card requires authentication for an off-session charge, you cannot force that through automatically, so flag the booking, notify the customer, and give them a link to complete the charge themselves.
How much should a booking deposit be?
Enough to cover your actual cost of a lost slot, not more. Service businesses commonly land between 20 and 50 percent of the total price, or a flat amount for shorter appointments where percentage math feels arbitrary to the customer.
Do I have to hold the deposit as a separate charge from the final payment?
Not necessarily. If the customer shows up, you can capture the same authorization as partial payment toward the total and charge the remainder separately at the appointment, rather than refunding the deposit and running a whole new charge.
How long can I hold a card authorization before I have to capture it?
Stripe generally releases manual-capture authorizations automatically after about seven days, though the exact window can vary by card network. Design your cutoff and capture logic around that limit rather than assuming an authorization stays open indefinitely.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


