Email cannot do this. A phone call to fifteen people takes forty minutes you do not have. A social post reaches people who are not looking. Texting a short waitlist is the only mechanism that works inside the window, and the window is about twenty minutes. The same mechanic fills a table at a restaurant, a bike in a fitness class and a dropped volunteer shift.
Build the waitlist before you need it
The list is the whole system. Ask at booking: 'Want a text if something opens up sooner?' Store the answer as its own consent scope — someone who wants gap alerts has not agreed to marketing, and someone who wants marketing has not agreed to be pinged at short notice.
type WaitlistEntry = { customerId: string; service: string; earliestWanted: string; // ISO date latestWanted: string; weekdaysOnly: boolean; lastPingedAt: string | null; // rate-limit per person}; // Fifteen people, most-recently-added-last, never pinged twice a week.export function candidatesFor(slot: Slot, list: WaitlistEntry[]) { return list .filter((e) => e.service === slot.service) .filter((e) => withinWindow(slot.startsAt, e)) .filter((e) => daysSince(e.lastPingedAt) >= 7) .slice(0, 15);}The message
export const gapAlert = (c: Ctx) => `${c.business}: we just had a ${c.time} today open up for ` + `${c.service}. First to reply Y gets it.`;Short, specific, and explicitly first-come. The urgency is real, which is why this message is allowed to be urgent — manufactured scarcity in a text is transparent and it costs you trust.
Handle the race condition
You are messaging fifteen people about one slot. Two will reply Y within seconds of each other. Claim the slot atomically on the first reply and send everyone else a real apology — 'that one went, but we have Thursday at 3' — immediately, not eventually. Getting this wrong means double-booking a customer who did everything right.
export async function claimSlot(slotId: string, customerId: string) { // Conditional update — only succeeds if the slot is still open. const claimed = await db.slot.updateMany({ where: { id: slotId, claimedBy: null }, data: { claimedBy: customerId, claimedAt: new Date() }, }); if (claimed.count === 0) { return { ok: false as const, reason: "already_taken" }; } return { ok: true as const };}Rate-limit yourself
- No more than one gap alert per person per week. This is the fastest way to burn a list.
- Only ping people whose stated window actually covers the slot. A 2pm Tuesday alert to someone who can only do evenings is noise.
- Stop pinging as soon as the slot is claimed. Nobody should get an alert for a slot that closed a minute ago.
- Respect quiet hours even when the slot is today.
Done well this is the clearest ROI in the whole channel: count the filled gaps, multiply by your average ticket, compare to the monthly bill. It is rarely close.
Next step
Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.