Work · Deposit-gated booking · Aesthetics clinic · single-treatment programme, via agency
Deposit-gated booking architecture
A diary full of confirmed bookings nobody had paid for
Rebuilt a clinic’s booking model so a slot is reserved on booking but only ever marked confirmed once the deposit is paid — spanning a Cloudflare booking worker, the CRM calendar, and four workflows that had all been using "confirmed" to mean different things.
- GoHighLevel
- Cloudflare Workers
- Conversation AI
- Stripe
- Role · sole designer & builder
- 6 min
- independent causes behind one phantom confirmation
- 3Scope
- workflows scanned in one live inventory pass
- 25Scope
- touches in the rebuilt 24-hour deposit chase
- 5Scope
- System size
- System flow · 9 stages · 40 components reconstructed from the build record
The situation
An aesthetics clinic ran a single-treatment programme where a deposit holds the appointment. Appointments arrived by three independent paths: a landing-page widget served by an edge worker, an AI agent booking inside its own flow-builder nodes, and the CRM's booking link.
The diary looked healthy. Patients appeared marked Confirmed, many had paid nothing, and nobody could tell which was which from the calendar.
That is a booking model problem rather than a bug in one place. Three systems each used the word confirmed to mean something different, and none meant the deposit had landed.
What was broken
- The booking route wrote confirmed when it created the appointment, before any payment could exist.
- The calendar independently auto-confirmed every arriving booking, so the AI path produced confirmed appointments while never setting a status.
- Four workflow triggers filtered on confirmed, using it as a proxy for "a booking exists" rather than "money arrived".
- Releasing an unpaid held slot cancelled it, dropping patients into cancellation-recovery copy.
- The deposit-paid rail had no staff notification, though the outstanding and released rails each had one.
Constraints
- Three arriving paths meant no fix at the caller could be complete. The AI path sets no status, so it was reachable only by changing what "no status" defaults to.
- The worker holds no state of its own. The CRM appointment record is the state machine, and every handler resolves state by re-reading the contact's appointments.
- The platform silently refuses trigger changes made through a full-document write, and a trigger created by POST returns an id but never attaches. Only a direct write to the individual trigger plus a publish works, which forced one atomic cutover. A phased migration was written first and abandoned.
FIG 01 · System Flow
How the system actually runs
Every component below is reconstructed from the project’s own build records. Nothing is added to make it look bigger.
- components
- 40
- components
- stages
- 9
- stages
- decisions & guards
- 12
- decisions & guards
- AI
- 2
- AI
- human handoffs
- 2
- human handoffs
- platforms
- 4
- platforms
- Stage 1, Booking entry points: Three independent paths by which an appointment can arrive, only one of which the fix could touch directly.
- Landing-page widget (Source, Browser). A framework-free widget served by the worker itself and mounted with a single async script tag, calling only the worker’s availability and booking routes. Connects to Availability + booking when patient picks a slot.
- AI agent booking node (AI, CRM Conversation AI). A flow-builder conversation agent over messaging and web chat that books inside dedicated booking nodes. Connects to Calendar auto-confirm off when books with no status set.
- CRM calendar link (Source, CRM). The third arriving path — the CRM’s own booking link — named alongside the agent and the landing page in the root-cause analysis. Connects to Calendar auto-confirm off when books with no status set.
- Stage 2, Booking worker: The stateless edge service that reads availability, writes the appointment, and holds no state of its own.
- Cloudflare booking worker (API / service, Cloudflare Workers). A small TypeScript worker split into a router, a CRM API layer, typed env, a CORS module, a validator and the widget source, exposing eight routes. Connects to Availability + booking when routes; Duplicate-booking collapse when keyed route.
- Availability + booking (API / service, Worker). One route returns free slots for a window — defaulting to a fortnight and hard-capped at the CRM’s own free-slots ceiling — and another validates the body, upserts the contact and creates the appointment, returning the appointment and contact ids with the status. Connects to Booking validator when every request; Origin + key gates when origin-gated.
- Booking validator (Guard, Worker). A discriminated-result validator enforcing a minimum notice, a forward cap, name and phone length limits and email and phone patterns. Connects to Slot written unconfirmed when validation passes.
- Origin + key gates (Guard, Worker). An exact-match origin allow-list on the public-facing routes, and a shared secret protecting the four server-to-server routes, which are deliberately not origin-gated because they are called from CRM workflows.
- Naive-local → UTC (Guard, Worker). The CRM’s appointment list returns naive local wall-times with no offset while the appointment write parses bare strings as UTC. Connects to Availability + booking when every time comparison or write.
- Duplicate-booking collapse (API / service, Worker). A keyed route that collapses duplicate future bookings for a contact.
- Stage 3, Appointment state model: Reserved, confirmed, released — and what "confirmed" is now allowed to mean.
- Slot written unconfirmed (Calendar, CRM). The booking write was changed from confirmed to a reserved status, so the slot is held against other bookers the moment the patient books while the diary shows it as unconfirmed. Connects to Three-state model; 24-hour hold when hold starts at booking; Deposit link + chase when reserved-status trigger fires.
- Calendar auto-confirm off (Calendar, CRM). The calendar’s auto-confirm setting was flipped off by a direct update, and the thank-you copy rewritten from "your assessment is confirmed" to "your assessment time is reserved". Connects to Slot written unconfirmed when auto-confirm off → default is reserved.
- Three-state model (Data store, CRM). Reserved — slot blocked, deposit unpaid, written by the booking route and by the calendar default. Confirmed — deposit paid, written only by the promote endpoint. Released — the record is deleted and the slot returns to the diary, written only by the release endpoint.
- 24-hour hold (Wait, CRM workflow). An unpaid reservation holds the slot for 24 hours, then it is released. Connects to Unpaid slot guard when T+24h and still unpaid.
- Stage 4, Deposit chase: A 24-hour ladder, its ordering, and the guards that stop a message breaking on a real merge value.
- Deposit link + chase (Workflow, CRM). Triggered on a reserved appointment on the assessment calendar: it tags link-sent, sends the deposit link by SMS and email, then runs a ladder of paid-checks and reminders. Connects to Two-minute ordering wait when so the link lands last.
- Recompressed to 24h (Message, SMS + email). The ladder was recompressed from a 72-hour shape to a 24-hour one: deposit link at T+0, reminder at T+3h, reminder plus the deposit-outstanding staff alert at T+12h, final reminder at T+22h, release at T+24h. Connects to Four paid-check gates when at each rung; Prefill redirect when every rung carries the short link; Rewritten staff alerts when deposit-outstanding alert at T+12h.
- Four paid-check gates (Decision, CRM). Four identical gates test whether the paid tag is present; the paid branch is a self-removal from the workflow. Connects to Deposit link + chase when paid tag present → self-exit; Unpaid slot guard when final paid-check fails.
- Single-segment SMS guard (Guard, Build tooling). Two independent implementations: an inline guard inside the migration script that aborts before any network call, and a full checker that computes segment counts for both encodings. Connects to Recompressed to 24h when every body, before any write.
- Two-minute ordering wait (Wait, CRM). A collision fix made after a lead received four texts and two emails inside one minute. Connects to Recompressed to 24h.
- Agent stops saying confirmed (AI, CRM Conversation AI). The agent’s end messages were replaced with reserved wording and its booking-node instructions rewritten. Connects to AI agent booking node when never claim confirmed.
- Stage 5, Payment: The prefill redirect, the deposit link, and the handler that runs when money lands.
- Prefill redirect (API / service, Worker). An unauthenticated redirect that looks the contact up and forwards to the deposit payment link with first name, last name and email prefilled. Connects to Deposit payment link when redirect with prefill.
- Deposit payment link (Payment, CRM-hosted, Stripe-backed). A CRM-hosted payment link backed by Stripe, moved from test to live mode on the payment link’s own edit screen. Connects to Deposit paid handler when payment received, filtered to the deposit product.
- Deposit paid handler (Workflow, CRM). Triggered by a payment event filtered to the deposit product: a leading gate no-ops if the paid tag is already present, otherwise it tags paid, calls the promote webhook, sends the confirmation, raises the staff alert, writes the amount onto the card and advances it. Connects to Deposit paid handler when paid tag already present → dead-end no-op; Mark-paid endpoint when keyed promote webhook; Four paid-check gates when the paid tag ends the chase.
- Stage 6, Promotion to confirmed: The keyed endpoint that resolves an appointment from a contact, and why its position in the step list is the design.
- Mark-paid endpoint (API / service, Worker). A keyed POST that resolves the appointment FROM the contact — selecting appointments on the clinic calendar still reserved, earliest first — and promotes the first to confirmed. Connects to Origin + key gates when wrong key refused; State-precondition idempotency when reserved-only precondition; Three-state model when reserved → confirmed; Deposit-paid staff alert when after the patient confirmation; Live endpoint proofs when confirmed, then no-op, then refused.
- State-precondition idempotency (Guard, Worker). There is no token, nonce or dedupe table: both new endpoints select only appointments still in the reserved status, so a repeat call finds nothing and returns a no-op.
- Promote before telling (Guard, CRM). The promote webhook is placed immediately after the paid tag and before the confirmation messages. Connects to Deposit paid handler when promote before the patient is told.
- Deposit-paid staff alert (Staff alert, CRM email). A new internal notification spliced between the patient confirmation email and the card step, addressed to the clinic inbox plus a second named staff recipient.
- Stage 7, Unpaid release: Freeing a held slot without ever telling a patient they cancelled something.
- Unpaid slot guard (Workflow, CRM). Rebuilt from an email asking a human to do something into the actual enforcement arm: the release webhook is inserted as step zero, made the parent of the previous root step. Connects to Release endpoint when step zero, before any card lookup; Rewritten staff alerts when slot-released alert.
- Release endpoint (API / service, Worker). A keyed POST that selects appointments on the clinic calendar still reserved AND strictly in the future, deletes every match, and returns the released ids or a no-op. Connects to Origin + key gates when keyed server-to-server; Late-payment race guard when reserved-only, strictly future; Delete, never cancel when delete the record; Live endpoint proofs when refused a paid booking, released a reserved one.
- Delete, never cancel (Guard, Worker). Deletion rather than cancellation, deliberately: deleting removes the appointment’s own workflow enrolments and fires no cancellation event, live-proven. Connects to Three-state model when record removed, slot back in the diary.
- Late-payment race guard (Guard, Worker). Only reserved appointments are eligible, so a deposit landing in the same minute as the release — already promoted to confirmed — can never have its slot deleted underneath it. Connects to Delete, never cancel when still reserved and still future.
- Rewritten staff alerts (Staff alert, CRM email). The outstanding alert was retimed and rewritten to say the reservation is released automatically at 24 hours, ending "call them first if you would rather keep the slot held"; the released alert reports what the automation did.
- Stage 8, The cutover: Four repointed triggers in one atomic pass, and the platform limit that forced it to be atomic.
- Four triggers, one cutover (Scheduled job, CRM). All four triggers had filtered on the assessment calendar plus a confirmed status; all four were repointed to the reserved status and renamed to say so. Connects to Deposit link + chase when repointed first — patient-visible; Duplicate-booking collapse when widened to see reserved bookings; Nothing left on confirmed when nothing left keyed on confirmed; Live endpoint proofs when verify the final trigger state.
- Nothing left on confirmed (Guard, CRM). The cutover deliberately leaves no trigger keyed on the confirmed status at all. Connects to Three-state model when one writer per state.
- Trigger-write platform limit (Guard, CRM API). The full-document write with trigger-change flags returns success and does nothing; creating a trigger by POST returns an id but produces an orphan that never attaches. Connects to Abandoned phased route when document write silently refuses.
- Abandoned phased route (Report, Build tooling). A phased migration was written first — clone each trigger verbatim, swap only the status value, run both live so the migration is order-independent, remove the old one just before promotion goes live. Connects to Four triggers, one cutover when phased route abandoned → one atomic pass.
- Migration safety harness (Guard, Build tooling). Present in every migration script: dry-run by default, a token-expiry pre-check, rate-limiting between calls, a JSON snapshot of pre-change state into a dated backups folder, a per-workflow abort if the expected shape is absent, and a non-zero exit on any failed verification. Connects to Four triggers, one cutover when dry-run, backup, verify from a fresh read.
- Stage 9, Proof & handover: Live endpoint proofs, an account-wide alert inventory, and a handbook that grades its own claims.
- Live endpoint proofs (Report, Live account). A proof section listing five items, captured as raw responses rather than narrative claims: a real reservation through the widget with its tags landing within seconds, promote then a repeat no-op then a wrong-key refusal, a release that refused a paid booking and then deleted a reserved one, the agent path defaulting to reserved, and the final trigger state. Connects to Owner handbook when proven behaviour becomes documented behaviour.
- Recorded as unproven (Report, Handover). What was left open, written down rather than glossed: the release is verified at the endpoint but no contact has yet walked the whole ladder, and the new deposit-paid alert was saved but had not fired live. Connects to Owner handbook when unverified claims generalised, never guessed.
- Account alert inventory (Report, CRM (read-only)). All 25 workflows exported and scanned against live data in one pass to verify four staff-alert rails end to end — lead in, booked, deposit paid, human takeover — and to add the second recipient. Connects to Deposit-paid staff alert when four alert rails verified end to end; Recorded as unproven when a missing alert path found and escalated.
- Owner handbook (Report, Deliverable). A self-contained, offline-verified page plus a PDF printed from it with page breaks verified, superseding an earlier guide written before this model existed.
A patient picks an assessment slot on the landing page at two in the afternoon and then goes quiet.
0/13A patient picks an assessment slot on the landing page at two in the afternoon and then goes quiet.
Read this system as text
Stage 01
Booking entry points
Three independent paths by which an appointment can arrive, only one of which the fix could touch directly.
- Landing-page widget · Source · Browser — A framework-free widget served by the worker itself and mounted with a single async script tag, calling only the worker’s availability and booking routes. → Availability + booking (patient picks a slot)
- AI agent booking node · AI · CRM Conversation AI — A flow-builder conversation agent over messaging and web chat that books inside dedicated booking nodes. → Calendar auto-confirm off (books with no status set)
- CRM calendar link · Source · CRM — The third arriving path — the CRM’s own booking link — named alongside the agent and the landing page in the root-cause analysis. → Calendar auto-confirm off (books with no status set)
Stage 02
Booking worker
The stateless edge service that reads availability, writes the appointment, and holds no state of its own.
- Cloudflare booking worker · API / service · Cloudflare Workers — A small TypeScript worker split into a router, a CRM API layer, typed env, a CORS module, a validator and the widget source, exposing eight routes. → Availability + booking (routes), Duplicate-booking collapse (keyed route)
- Availability + booking · API / service · Worker — One route returns free slots for a window — defaulting to a fortnight and hard-capped at the CRM’s own free-slots ceiling — and another validates the body, upserts the contact and creates the appointment, returning the appointment and contact ids with the status. → Booking validator (every request), Origin + key gates (origin-gated)
- Booking validator · Guard · Worker — A discriminated-result validator enforcing a minimum notice, a forward cap, name and phone length limits and email and phone patterns. → Slot written unconfirmed (validation passes)
- Origin + key gates · Guard · Worker — An exact-match origin allow-list on the public-facing routes, and a shared secret protecting the four server-to-server routes, which are deliberately not origin-gated because they are called from CRM workflows.
- Naive-local → UTC · Guard · Worker — The CRM’s appointment list returns naive local wall-times with no offset while the appointment write parses bare strings as UTC. → Availability + booking (every time comparison or write)
- Duplicate-booking collapse · API / service · Worker — A keyed route that collapses duplicate future bookings for a contact.
Stage 03
Appointment state model
Reserved, confirmed, released — and what "confirmed" is now allowed to mean.
- Slot written unconfirmed · Calendar · CRM — The booking write was changed from confirmed to a reserved status, so the slot is held against other bookers the moment the patient books while the diary shows it as unconfirmed. → Three-state model, 24-hour hold (hold starts at booking), Deposit link + chase (reserved-status trigger fires)
- Calendar auto-confirm off · Calendar · CRM — The calendar’s auto-confirm setting was flipped off by a direct update, and the thank-you copy rewritten from "your assessment is confirmed" to "your assessment time is reserved". → Slot written unconfirmed (auto-confirm off → default is reserved)
- Three-state model · Data store · CRM — Reserved — slot blocked, deposit unpaid, written by the booking route and by the calendar default. Confirmed — deposit paid, written only by the promote endpoint. Released — the record is deleted and the slot returns to the diary, written only by the release endpoint.
- 24-hour hold · Wait · CRM workflow — An unpaid reservation holds the slot for 24 hours, then it is released. → Unpaid slot guard (T+24h and still unpaid)
Stage 04
Deposit chase
A 24-hour ladder, its ordering, and the guards that stop a message breaking on a real merge value.
- Deposit link + chase · Workflow · CRM — Triggered on a reserved appointment on the assessment calendar: it tags link-sent, sends the deposit link by SMS and email, then runs a ladder of paid-checks and reminders. → Two-minute ordering wait (so the link lands last)
- Recompressed to 24h · Message · SMS + email — The ladder was recompressed from a 72-hour shape to a 24-hour one: deposit link at T+0, reminder at T+3h, reminder plus the deposit-outstanding staff alert at T+12h, final reminder at T+22h, release at T+24h. → Four paid-check gates (at each rung), Prefill redirect (every rung carries the short link), Rewritten staff alerts (deposit-outstanding alert at T+12h)
- Four paid-check gates · Decision · CRM — Four identical gates test whether the paid tag is present; the paid branch is a self-removal from the workflow. → Deposit link + chase (paid tag present → self-exit), Unpaid slot guard (final paid-check fails)
- Single-segment SMS guard · Guard · Build tooling — Two independent implementations: an inline guard inside the migration script that aborts before any network call, and a full checker that computes segment counts for both encodings. → Recompressed to 24h (every body, before any write)
- Two-minute ordering wait · Wait · CRM — A collision fix made after a lead received four texts and two emails inside one minute. → Recompressed to 24h
- Agent stops saying confirmed · AI · CRM Conversation AI — The agent’s end messages were replaced with reserved wording and its booking-node instructions rewritten. → AI agent booking node (never claim confirmed)
Stage 05
Payment
The prefill redirect, the deposit link, and the handler that runs when money lands.
- Prefill redirect · API / service · Worker — An unauthenticated redirect that looks the contact up and forwards to the deposit payment link with first name, last name and email prefilled. → Deposit payment link (redirect with prefill)
- Deposit payment link · Payment · CRM-hosted, Stripe-backed — A CRM-hosted payment link backed by Stripe, moved from test to live mode on the payment link’s own edit screen. → Deposit paid handler (payment received, filtered to the deposit product)
- Deposit paid handler · Workflow · CRM — Triggered by a payment event filtered to the deposit product: a leading gate no-ops if the paid tag is already present, otherwise it tags paid, calls the promote webhook, sends the confirmation, raises the staff alert, writes the amount onto the card and advances it. → Deposit paid handler (paid tag already present → dead-end no-op), Mark-paid endpoint (keyed promote webhook), Four paid-check gates (the paid tag ends the chase)
Stage 06
Promotion to confirmed
The keyed endpoint that resolves an appointment from a contact, and why its position in the step list is the design.
- Mark-paid endpoint · API / service · Worker — A keyed POST that resolves the appointment FROM the contact — selecting appointments on the clinic calendar still reserved, earliest first — and promotes the first to confirmed. → Origin + key gates (wrong key refused), State-precondition idempotency (reserved-only precondition), Three-state model (reserved → confirmed), Deposit-paid staff alert (after the patient confirmation), Live endpoint proofs (confirmed, then no-op, then refused)
- State-precondition idempotency · Guard · Worker — There is no token, nonce or dedupe table: both new endpoints select only appointments still in the reserved status, so a repeat call finds nothing and returns a no-op.
- Promote before telling · Guard · CRM — The promote webhook is placed immediately after the paid tag and before the confirmation messages. → Deposit paid handler (promote before the patient is told)
- Deposit-paid staff alert · Staff alert · CRM email — A new internal notification spliced between the patient confirmation email and the card step, addressed to the clinic inbox plus a second named staff recipient.
Stage 07
Unpaid release
Freeing a held slot without ever telling a patient they cancelled something.
- Unpaid slot guard · Workflow · CRM — Rebuilt from an email asking a human to do something into the actual enforcement arm: the release webhook is inserted as step zero, made the parent of the previous root step. → Release endpoint (step zero, before any card lookup), Rewritten staff alerts (slot-released alert)
- Release endpoint · API / service · Worker — A keyed POST that selects appointments on the clinic calendar still reserved AND strictly in the future, deletes every match, and returns the released ids or a no-op. → Origin + key gates (keyed server-to-server), Late-payment race guard (reserved-only, strictly future), Delete, never cancel (delete the record), Live endpoint proofs (refused a paid booking, released a reserved one)
- Delete, never cancel · Guard · Worker — Deletion rather than cancellation, deliberately: deleting removes the appointment’s own workflow enrolments and fires no cancellation event, live-proven. → Three-state model (record removed, slot back in the diary)
- Late-payment race guard · Guard · Worker — Only reserved appointments are eligible, so a deposit landing in the same minute as the release — already promoted to confirmed — can never have its slot deleted underneath it. → Delete, never cancel (still reserved and still future)
- Rewritten staff alerts · Staff alert · CRM email — The outstanding alert was retimed and rewritten to say the reservation is released automatically at 24 hours, ending "call them first if you would rather keep the slot held"; the released alert reports what the automation did.
Stage 08
The cutover
Four repointed triggers in one atomic pass, and the platform limit that forced it to be atomic.
- Four triggers, one cutover · Scheduled job · CRM — All four triggers had filtered on the assessment calendar plus a confirmed status; all four were repointed to the reserved status and renamed to say so. → Deposit link + chase (repointed first — patient-visible), Duplicate-booking collapse (widened to see reserved bookings), Nothing left on confirmed (nothing left keyed on confirmed), Live endpoint proofs (verify the final trigger state)
- Nothing left on confirmed · Guard · CRM — The cutover deliberately leaves no trigger keyed on the confirmed status at all. → Three-state model (one writer per state)
- Trigger-write platform limit · Guard · CRM API — The full-document write with trigger-change flags returns success and does nothing; creating a trigger by POST returns an id but produces an orphan that never attaches. → Abandoned phased route (document write silently refuses)
- Abandoned phased route · Report · Build tooling — A phased migration was written first — clone each trigger verbatim, swap only the status value, run both live so the migration is order-independent, remove the old one just before promotion goes live. → Four triggers, one cutover (phased route abandoned → one atomic pass)
- Migration safety harness · Guard · Build tooling — Present in every migration script: dry-run by default, a token-expiry pre-check, rate-limiting between calls, a JSON snapshot of pre-change state into a dated backups folder, a per-workflow abort if the expected shape is absent, and a non-zero exit on any failed verification. → Four triggers, one cutover (dry-run, backup, verify from a fresh read)
Stage 09
Proof & handover
Live endpoint proofs, an account-wide alert inventory, and a handbook that grades its own claims.
- Live endpoint proofs · Report · Live account — A proof section listing five items, captured as raw responses rather than narrative claims: a real reservation through the widget with its tags landing within seconds, promote then a repeat no-op then a wrong-key refusal, a release that refused a paid booking and then deleted a reserved one, the agent path defaulting to reserved, and the final trigger state. → Owner handbook (proven behaviour becomes documented behaviour)
- Recorded as unproven · Report · Handover — What was left open, written down rather than glossed: the release is verified at the endpoint but no contact has yet walked the whole ladder, and the new deposit-paid alert was saved but had not fired live. → Owner handbook (unverified claims generalised, never guessed)
- Account alert inventory · Report · CRM (read-only) — All 25 workflows exported and scanned against live data in one pass to verify four staff-alert rails end to end — lead in, booked, deposit paid, human takeover — and to add the second recipient. → Deposit-paid staff alert (four alert rails verified end to end), Recorded as unproven (a missing alert path found and escalated)
- Owner handbook · Report · Deliverable — A self-contained, offline-verified page plus a PDF printed from it with page breaks verified, superseding an earlier guide written before this model existed.
Platforms · GoHighLevel · Cloudflare Workers · Conversation AI · Stripe
What I built
One definition of confirmed
I redefined the states before touching a workflow, because no amount of trigger repair fixes an ambiguous word. Reserved means the slot is blocked and the deposit unpaid, written by the booking route and the calendar default. Confirmed means the deposit is paid, written only by the promote endpoint. Released means the record is deleted and the slot back in the diary, written only by the release endpoint. One writer per state is what stopped three systems using one word three ways.
The AI path was fixed by doing nothing to the AI. Auto-confirm was flipped off on the calendar, so an appointment created with no status defaults to reserved and the agent reserves too.
Two keyed endpoints, idempotent by construction
The worker gained a mark-paid endpoint and a release endpoint, both keyed and server-to-server. Mark-paid resolves the appointment from the contact, because the payment-triggered workflow carries no appointment context and the CRM's status action has nothing to act on. There is no token, nonce or dedupe table anywhere: both endpoints select only appointments still reserved, so a repeat call finds nothing and returns a no-op. They are deliberately asymmetric, since promote allows a slot that started up to an hour ago while release takes only future ones.
Release by deletion, never by cancellation
Freeing an unpaid hold deletes the record rather than cancelling it, and the source comment states both reasons. No cancellation event means recovery copy written for someone who chose to cancel cannot fire at someone who did not. Deleting also clears the appointment's own workflow enrolments, so no orphaned reminder ladder runs against a slot that no longer exists. The release webhook is step zero of the guard workflow, ahead of the card lookup. The workflow it replaced had been an email asking a human to do it by hand.
A 24-hour ladder, and the order of the first two messages
The chase was recompressed from a 72-hour shape into the hold itself: deposit link at T+0, a reminder at T+3h, a reminder plus the deposit-outstanding staff alert at T+12h, a final reminder at T+22h, release at T+24h. It was implemented as wait-duration edits only, and the script refuses to finish unless the four waits sum to exactly 24 hours.
A two-minute ordering wait sits in front of the link so it lands last, which is where the patient looks. An earlier pass had removed that wait to make the link arrive sooner, and it raced ahead of the reservation message instead.
Promote before telling, and one cutover
The promote webhook sits immediately after the paid tag and before the confirmation messages, so the diary already reads confirmed when the patient is told it is. Put it after, and the patient has been told something the system does not yet believe.
All four triggers were then repointed from confirmed to reserved in one ordered pass, the patient-visible deposit rail first. Nothing is left keyed on confirmed, because a surviving trigger there would re-enrol the patient the moment promotion started and re-send the whole reservation cycle. An account-wide alert inventory then verified four staff rails end to end and added the deposit-paid notification the account had never had.
Edge cases and failure handling
- A patient books but never pays. The final paid-check fails at T+24h and the release endpoint deletes the reserved slot, returning it to the diary.
- A deposit lands in the same minute as the release. Only reserved appointments are eligible, so one already promoted to confirmed cannot have its slot deleted underneath it.
- A second payment event arrives for the same deposit. A leading gate in the paid handler is a deliberate dead end, so nothing re-runs.
- A merge value expands a message past one segment. The checker expands real live values read back from the account and substitutes a long stand-in for any unresolved token, so no message passes that would break when the real value lands.
- Two legacy appointments sit confirmed but unpaid. They predate the model, so the guard will never release them, and flipping them to reserved would re-fire the reservation messages. The instruction on file is explicitly not to.
How I verified it
Five behaviours were captured as raw responses rather than narrative claims: a real reservation through the widget, a promote followed by a repeat no-op and a wrong-key refusal, a release that refused a paid booking and then deleted a reserved one, the agent path defaulting to reserved, and the final trigger state. The idempotency and wrong-key checks were issued as one command, so both results are provably from the same run.
Each migration script ran dry by default, snapshotted pre-change state, aborted if the expected shape was absent, and verified from a fresh read rather than the write response. All 25 workflows were exported and scanned against live data in one pass.
What was not proven was written down as not proven. No contact has yet walked the whole ladder end to end, and the deposit-paid alert was configured and saved but had not fired live at handover.
What changed
A slot now holds unconfirmed while the deposit is chased, and confirmed means paid. One state model runs across the worker, the calendar and the workflows. Fixing any two of the three causes would have been worse than fixing none: repair the worker and the calendar without repointing the triggers, and nothing matches any trigger, so no patient ever receives a deposit link.
Unpaid holds delete cleanly, so no recovery copy misfires at a patient who never cancelled, and the paid rail raises the staff alert it never had.
The clinic owns the documentation as well as the system. The owner handbook is a self-contained page plus a PDF printed from it, every claim graded as verified from live config, verified by observation, or unverified. Screenshots were captured with patient data blurred in the page before capture.
Before / after
- Before
- ✕ Diary showed Confirmed with no deposit paid
- ✕ Three systems used "confirmed" to mean three different things
- ✕ Releasing a held slot dropped patients into cancellation copy
- ✕ No staff alert when a deposit actually landed
- After
- ✓ A slot holds unconfirmed for 24 hours; confirmed means paid
- ✓ One state model across worker, calendar and workflows
- ✓ Unpaid holds delete cleanly — no misfired recovery copy
- ✓ Deposit-paid raises the alert it never had
ScopeFigures on this page describe what was built, covered or verified — not a measured lift.
Related systems
Not a one-off.
Aesthetics clinic · via agency
Live launch & fault triage
Took a clinic’s already-"finished" lead-response system live for a paid campaign — and found and fixed five faults that had each been silently costing bookings.
5silent faults fixed in a "finished" buildScope
Recurring membership offer
Subscription Funnel
Both gate access on a payment actually landing.
+35%retentionMeasured
Marketing agency · nine clinic builds
White-label agency delivery
Embedded as the automation build arm of a marketing agency: nine complete GoHighLevel acquisition systems delivered into clinic sub-accounts across two countries in five and a half weeks — nurture workflows, AI receptionists, booking calendars, deposit handling, landing pages, and the owner handbooks the agency handed over..
9client builds in 5.5 weeksScope
Have something close to this? Book a call. I’ll tell you if I’ve already built it.
One Manila business day to reply. All client work is under NDA; what you see here is redrawn architecture with real metrics.