Skip to content

The URL Is the Invite

How a 10-character token, a DynamoDB row, and a /r/:token route turn a postpartum SMS into a personalized annual-exam booking page.

• Matt Dennis

Honoria Wobblethorpe delivered about a year ago. The office wants her back for an annual exam. The text should sound like Dr. Ray, the page should already know her name, and the calendar should not dump every empty Monday onto a public form.


The link we send is https://book.drjuliaray.com/r/K7mQ2nXpR1. That last path segment is the whole invitation.


The token is the auth

A staff CLI mints one row in DynamoDB BookingInvites and prints the URL. Ten characters from a URL-safe alphabet (A–Z, a–z, 0–9, -, _). No JWT. No session. Guessing is a 64¹⁰ space; we do not enumerate.


The row holds what the page needs and nothing extra: firstName, fullName, phone (E.164), email, campaign, createdAt, and expiresAt as epoch seconds. Dynamo TTL deletes it in about 180 days. The CLI does not send SMS. It writes the row and stops.


node scripts/generate-booking-invite.js \
  --name "Honoria Wobblethorpe" \
  --phone +18055550199 \
  --campaign pp-annual-2026-09

That prints one URL. Campaign is a string we can query later (pp-annual-2026-09-mar-may versus jun-jul), not a second table.


What the page does

/r/:token is the returning-patient flow with a different intro. Annual exam is already selected. The reason step is skipped. The contact form comes prefilled.


On load the app GETs ?invite=K7mQ2nXpR1. The Lambda looks up the token, returns the contact fields, and stamps clickedAt once. A missing or expired token does not bounce to /returning. She sees Hi there, and an empty form, same Dr. Ray note.


The greeting waits. Four cream bars sit where the name and paragraphs will go. Pick a time is already on the card, disabled, so the chrome does not pop. When the fetch lands: Hi Honoria, then three short paragraphs, then a signoff. No flash of the fallback name.


The note on the page is calmer than the SMS. The text is Dr. Ray’s voice. The page is the office asking her to pick a time. Those are different jobs.


Slots the public form does not get

/returning still protects a four-day week: one of Monday or Friday, never both. Invite links skip that. Honoria can see Monday and Friday in the same week.


She still only gets one time per day. An empty day offers 10:00. A day with visits offers the tight join — 15-minute grid, 60-minute appointment, 15-minute buffers — so a 11:30 prenatal becomes a 10:15 annual, not a hole at 10:00 and another chip at 1:00.


The browser never talks to Google Calendar. gyn-booking-api curates the list. The flag is invite=1 on the slots GET, plus returning=1, so /returning stays on the old policy.


That flag almost ate the token lookup. The invite record is also GET ?invite=<token>. First deploy, slot requests sent invite=1 and the handler treated "1" as a nanoid. Four hundred and four. The schedule page died. The fix is one line: if the query has a start date, it is slots.


if (query.invite && !query.start) return await handleGetInvite(query.invite);

Same parameter, two jobs, a date range to tell them apart. I will not reuse invite= for a boolean again.


Booking writes back

When she picks 10:00 on an empty Monday, the POST includes inviteToken. The booking row in GynBookings stores it. The invite row gets bookedAt and bookingId. An unknown token does not fail the booking. We would rather have the appointment than a 400 because someone forwarded a mangled URL.


SMS send is a separate pass. Make SendOpenPhoneText from the office number, then UpdateItem with smsSentAt, smsBody, and smsSentTo. Condition: attribute_not_exists(smsSentAt). The working list is a markdown file. Dynamo is the source of truth. The CLI that minted the link still does not send.


Mar–May deliveries get “over a year.” Jun–Jul can stay “a year.” Same page. Different sentence in the text. Anyone already on the calendar for an annual does not get a link in the first place.


Honoria’s URL is a row, a greeting, and one honest time. The rest of the practice stack does not need to know she clicked it until she books.