Building a Booking System With Deposits and Cancellation Rules
A booking system with deposits and cancellation rules is buildable in weeks, not months, but it is not the CRUD form vendors quote it as. The money and the calendar have to stay consistent across two systems that fail independently. Realistic bands from Digital Heroes delivery across 2,000+ projects: $15,000 to $60,000 to add this capability properly inside an existing product, $50,000 to $130,000 in 10 to 16 weeks as part of a first release, and $150,000 to $350,000 over 6 to 12 months for a full multi-resource platform with external calendar sync and a real policy engine. The number moves on how many resource types get co-booked and how big your cancellation matrix actually is once someone writes it down.
What this looks like once real people touch it
Saturday, 11:04am. Two customers open the 2:00pm Tuesday slot on their phones and both tap Book inside the same 300 milliseconds. The naive implementation runs a SELECT to check availability, sees the slot free, then INSERTs. Both requests read before either wrote. Now there are two confirmed bookings for one chair, two deposits charged, and nobody finds out until 1:58pm on Tuesday when the second customer is standing at reception holding a confirmation email with your logo on it.
You refund them. They dispute it anyway, because the refund takes five business days and they are annoyed. That costs you a dispute fee (currently $15 in the US on Stripe) whether you win or lose. This is not an exotic race you can shrug off at low volume: one popular slot on one busy morning is enough. And the fix is not in your application code. It is in your schema.
The slot is a database constraint, not an if statement
The only reliable place to prevent a double booking is the database, enforced by the engine, with no application code in the loop. In PostgreSQL that means the btree_gist extension and an exclusion constraint over a time range: EXCLUDE USING gist (resource_id WITH =, during WITH &&), where "during" is a tstzrange. Two concurrent transactions cannot both win. One gets a constraint violation and you turn that into a clean "slot just went" message. If your developer's answer is "we wrap it in a transaction", ask which isolation level. READ COMMITTED, the Postgres default, does not stop this. SERIALIZABLE does, at the price of serialisation failures your code must catch and retry.
Next comes the hold. Payment takes 20 to 90 seconds of human time, and during that window the slot must be reserved but not confirmed. So a booking is not one row state, it is a state machine: held with an expires_at, confirmed, cancelled_by_customer, cancelled_by_staff, no_show, completed. Something has to sweep abandoned holds, because abandoned checkouts are routine and those slots must come back. A cron sweep over an index on expires_at is fine at small scale. Durable job runners such as Temporal, Inngest or Trigger.dev earn their keep once reminders, hold expiry and refund retries all need to survive a deploy.
Then multi-resource. A massage needs a therapist, a room, and sometimes a specific table. A charter needs a boat, a skipper, and a berth. Two concurrent bookings that lock those resources in different orders will deadlock in Postgres, and the naive per-resource check will happily half-book. Lock resources in a deterministic order, sorted by resource id, always. Group classes add capacity and waitlists, and waitlist promotion when a seat opens is the same race wearing a different hat.
Time is not a number
"Just store everything in UTC" is correct for a one-off booking and wrong for a recurring one. A client books Tuesdays at 9:00am in Denver starting in February. You store the UTC instants. On the second Sunday in March, America/Denver shifts, and every future occurrence is now at 8:00am local. The client shows up an hour late for a session they paid a deposit on and blames you.
The correct model is local wall time plus the IANA time zone identifier (America/Denver, not "MST", and never a fixed offset like -07:00), resolved to UTC at read time. The tz database is not stable: governments change DST rules with weeks of notice, and tzdata ships multiple releases a year. Any offset you baked into a row is a bug waiting for a legislature.
Recurrence has a standard, RFC 5545, the same one iCalendar uses. Use its RRULE, EXDATE and RDATE semantics rather than inventing your own repeat table, because the moment a client says "cancel just next week's" or "move this one and all following", you are re-deriving iCalendar badly. Two nasty cases fall out of DST that most builds never handle: on spring forward, 2:30am does not exist, and on fall back, 1:30am happens twice. Decide what your booking engine does with both before a customer discovers it.
And the cancellation clock has a time zone too. "48 hours before" a 9:00am Monday appointment is 9:00am Saturday. If the customer is in London and the salon is in Chicago, whose 48 hours? Write it down. It is a business decision and it belongs in the contract.
The deposit is a distributed transaction, and it will fail halfway
The most common bad instinct: "just put a hold on the card." Card authorisations are not booking holds. On Stripe, an uncaptured PaymentIntent with capture_method set to manual is generally good for about 7 days on most cards, and shorter on some networks. A booking six weeks out cannot be secured with an auth.
The real options are: charge the deposit now and refund per policy, or save the payment method with a SetupIntent and charge later off_session. The second path has a trap. Off-session charges get declined with authentication_required when the issuer wants a 3D Secure challenge, and under PSD2 in Europe you need a valid mandate recorded at setup time. Your code must catch that decline, email the customer a link to authenticate, and hold the booking while it waits. That branch alone is usually a week of work nobody quoted.
Then idempotency. Every create call to Stripe gets an Idempotency-Key derived from your booking attempt, not generated fresh per request, or a customer with flaky hotel wifi who taps Pay twice gets charged twice. Webhooks are at-least-once and unordered: payment_intent.succeeded can arrive before your own API call has returned, can arrive twice, and can arrive for a booking the sweeper already cancelled. Stripe retries failed webhooks with backoff for up to three days. Your handler must be idempotent on the event id and must be a state machine rather than a sequence of assumptions. Never treat the browser redirect as proof of payment.
The failure you are actually engineering against: payment succeeds, the booking INSERT loses the exclusion-constraint race, and you are holding a stranger's $200 with no appointment attached. Put booking_id in the PaymentIntent metadata, run a reconciliation job that compares Stripe's ledger to your bookings table daily, and auto-refund orphans. Every mature booking build has this job. Every immature one learns it needs one from a customer.
The cancellation policy is versioned data, and one day you will defend it
Vendors quote cancellation as an if statement. In our delivery data it is the most under-scoped item on a booking project: a policy that sounds like one sentence in a kickoff call has consistently expanded to 20 to 30 distinct branches once written out. Tiered refunds (100% over 7 days, 50% between 2 and 7 days, 0% inside 48 hours). No-show versus late cancel, which are different amounts. Reschedule, which may or may not reset the clock and may or may not be allowed once. Staff-initiated cancellation, which is always a full refund. Weather and force majeure overrides. Deposits that are non-refundable but transferable once. Multiply by actor (customer, staff, admin) and by service type.
Two decisions separate a build that survives from one that does not.
First, snapshot the policy onto the booking. Store the policy version id and the exact rendered text the customer saw, not a foreign key to the live policy row. If the owner tightens the terms in March, every booking made in January must still run under January's terms. If your developer says "it just reads the current policy", that is a lawsuit-shaped bug.
Second, build the dispute evidence on day one. When a no-show disputes their deposit, the card networks route it under a cancelled-services reason code (Visa 13.7 territory), and you get a short window to respond through Stripe's dispute evidence API. What wins is boring and specific: timestamped proof the customer accepted that exact policy text at checkout, with a hash of the text, the IP, the user agent, plus the confirmation email showing the policy inline and the service date. If you did not record it at booking time you cannot manufacture it later, and you lose by default. Retrofitting evidence capture is impossible, which is why it belongs in version one.
One more thing that surprises owners: Stripe does not return the original processing fee when you issue a refund. A 50% refund on a $200 deposit does not leave the business with $100. It leaves roughly $94, after the 2.9% plus 30 cents already paid on the way in. Somebody eats that. Decide who, and put it in the policy text.
The calendar you do not control
If your staff keep their own Google or Outlook calendars, your availability is a lie the moment someone books their dentist outside your app.
Google Calendar gives you incremental sync with a syncToken and a 410 GONE when that token goes stale, which means you must fall back to a full resync and handle it gracefully rather than crash-looping. Push notification channels expire in days, not months, and must be renewed on a schedule. Microsoft Graph calendar subscriptions cap out at 4230 minutes, under three days, and also need proactive renewal. Refresh tokens get revoked when a user changes their password or an admin rotates consent, silently.
The failure mode is the dangerous kind: nothing errors, sync just stops, availability goes stale, and you double-book a real human. The competent build alerts on the age of the last successful sync per connected calendar, not on exceptions. If you cannot answer "how would we know sync broke", you do not have sync, you have a countdown.
What it costs and how long it takes
These are Digital Heroes bands from delivery across 2,000+ projects, not industry averages.
Adding deposits and cancellation rules to an existing product that already has auth, a jobs runner and a payments integration: $15,000 to $60,000. Building it as part of a first release, with the booking flow, staff console and policy engine: $50,000 to $130,000 over 10 to 16 weeks. A full platform with multi-resource scheduling, external calendar sync, waitlists, marketplace-style supply and a real policy engine: $150,000 to $350,000 over 6 to 12 months.
What specifically moves this capability's number:
Co-booked resource types. One resource is a week. Three resources that must all be free simultaneously, with buffers and travel time, is a different engine and roughly doubles the scheduling work.
The size of the policy matrix. Each row of tier by actor by service type is a rule and a test. Ten rules is fine. Forty rules is a month.
External calendar sync. Budget $8,000 to $15,000, and accept that it is the largest single source of post-launch tickets forever.
More than one country. SCA mandates, 3D Secure fallbacks, and local methods such as iDEAL or SEPA Direct Debit carry different refund windows and different dispute rules than cards. Each one is real work.
Staff override tooling. The receptionist needs to waive a fee, force a partial refund, move a booking outside policy, and rebook a no-show. This is routinely 30% of the build and is almost never in the brief.
Migrating live bookings that already have money attached, from a spreadsheet or from Acuity, with no downtime and correct policy mapping. Two to three weeks on its own.
When you should not build this
Most people asking this question should buy, and I will name names.
If your bookable resources are humans with calendars, your policy fits on one page, and booking is a feature of your business rather than the product itself, use Cal.com, Acuity Scheduling or Square Appointments. Cal.com is open source and self-hostable, has a real API and platform tier, and already handles the DST and RRULE problems you are about to rediscover. Square Appointments has a free tier and takes deposits with card on file at standard card rates. Acuity sits in the low tens of dollars per month at list for a small operation. Any of them beats a $40,000 custom build on both cost and correctness for a salon, a clinic, a studio, or a consultancy.
The honest test: can you describe every cancellation outcome in a single page, and is every resource a person with a Google Calendar? If yes, buy. You will be live next week.
Build custom when booking is the product, not the plumbing. Two-sided supply where the providers are your inventory. Assets with attributes that must be matched, like boats by capacity or rooms by equipment. Policies that are contractual rather than conventional, meaning legal has an opinion. Or booking data that has to join to the rest of your domain, where availability depends on stock, staff certifications, or a job in progress. Off-the-shelf tools cannot reach into your data model, and the integration tax will exceed the build.
Either way: do not build a payments layer. Use Stripe.
How to brief and vet a developer for this
Bring the cancellation matrix to the first call, written out, every row. If you have not written it, you are not ready to brief, and any quote you receive is fiction.
Then ask these, in this order. They expose someone who has read a tutorial rather than shipped this.
"Two people click the same slot in the same 100 milliseconds. Show me in the schema where that is prevented." You want a database constraint named out loud. Application-level checking or a vague "we use transactions" means they have not hit it.
"A client books recurring Tuesdays at 9am in Denver starting in February. What happens on the second Sunday in March?" If DST does not come up unprompted, stop.
"We take a $200 deposit for a booking six weeks out. Auth hold or charge?" If they say hold the card, they have not read Stripe's documentation on authorisation expiry.
"Your webhook handler gets payment_intent.succeeded twice, and the second time the booking is already cancelled. What happens?" You want idempotency on the event id and an explicit state machine.
"The owner edits the cancellation policy today. What happens to a booking taken last week?" If the answer is "it updates", walk.
"A no-show disputes their deposit. What exactly do we send Stripe?" If they cannot list the evidence fields, nobody is capturing them.
"What sweeps abandoned holds, and what happens if that job dies for six hours?"
Finally, ask them to draw the booking state machine on a whiteboard, unprompted. Count the states. If refunds are a boolean rather than their own states with amounts, they have never processed a partial refund against a tiered policy, which is the entire job you are hiring for.
The evidence behind this guide
Independent findings on why this investment pays off. Every link goes to the primary source.
- In an RCT, the no-show rate was 23.5% for patients receiving a text-message reminder versus 38.1% for the control group - a 14.6 percentage-point reduction (p = 0.04). Source: Clinical Pediatrics / PubMed Central (Lin et al.) (2016) →
- In an RCT, text-message reminders (11.7% missed) were non-inferior to telephone reminders (10.2% missed; difference not significant, within the 2% non-inferiority margin) but far cheaper - total cost EUR 230 for SMS versus EUR 8,910 for telephone over 6 months - making SMS more cost-effective. Source: BMC Health Services Research / PubMed Central (Junod Perron et al.) (2013) →
- Per Sensor Tower's State of Mobile 2026, worldwide consumers spent about $85 billion on apps in 2025 (up 21% YoY), and for the first time non-game apps surpassed games in consumer spending; generative-AI in-app purchase revenue more than tripled to top $5 billion. Source: Sensor Tower (via TechCrunch) (2026) →
- Median SaaS spend reached $9,455 per employee, and organizations leave an average of 36% of their SaaS licenses unused. Source: Zylo (2026) →
Rohan advises mid-market and enterprise teams on ERP, CRM and custom software, and has led delivery on dozens of business-software builds.
Writes for Digital Heroes, shipping business software for 2,000+ brands across 55+ countries since 2017.