Skip to content

Guest Phone Possession Verification — Spec

Status: design locked 2026-07-13, implementation not started. Governed by ADR-024. Execution phases in plan.md. Hard prerequisites: PR #843 (ADR-022 phone auth) and PR #880 (scheduler refactor / guest surfaces) merged.

What this is

A platform capability: prove an unauthenticated visitor controls a phone number, record that proof per transaction, and let flows consume it. First consumer is the comp booking flow (requiresVerifiedPhone offers, i.e. CHERRY_CITY_FIRST_PRACTICE). The capability also detects "this phone belongs to an existing member" after possession is proven and routes the person to phone-OTP sign-in, preventing duplicate accounts at the guest front door.

What it is not: identity verification. GoTrue owns identity phone (ADR-022). Nothing here writes auth.users.phone, public.users.phone_verified_at, or users.phone verified state. See ADR-024 for the rejected alternatives.

Data model

model PhoneVerification {
  id         String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  phone      String                                  // E.164, normalized via normalizePhoneToE164
  purpose    String                                  // 'comp_booking' for v1; future: 'guest_checkout', 'booking_management'
  verifiedAt DateTime? @map("verified_at")           // set on successful Twilio check
  consumedAt DateTime? @map("consumed_at")           // set once by the consuming flow, in-tx
  consumedBy String?   @map("consumed_by")           // e.g. promo_redemptions.id
  ipAddress  String?   @map("ip_address")
  createdAt  DateTime  @default(now()) @map("created_at")

  @@index([phone, purpose, verifiedAt])
  @@map("phone_verifications")
}
  • Validity window: a verification is consumable for 30 minutes after verifiedAt and exactly once. Long enough to finish the booking form, short enough that a leaked id is near-useless.
  • Retention: rows hold phone + IP (PII); the check route opportunistically prunes rows older than 30 days on its success path (same trick as the rate limiter). Consumed proofs keep their audit value via PromoRedemption.phone.
  • PromoRedemption gains phone String? (E.164) with @@unique([offerId, phone]) — as built, a plain unique rather than the originally planned raw-SQL partial; Postgres NULLS DISTINCT gives identical semantics (see plan.md deviations). This is the cross-identity redemption cap.

API

POST /api/phone-verification (public: send)

Body: { phone, purpose }. Normalizes to E.164 (400 on failure), enforces rate limits, calls Twilio Verify verifications.create, returns { sent: true }. Reveals nothing about accounts (nothing to leak pre-possession; see oracle note below).

Rate limits via enforcePublicRateLimit (#880's DB-backed helper), committed outside the main work like the comp route does:

  • phone-otp-send:phone:<e164> — 5 / hour
  • phone-otp-send:ip:<ip> — 10 / hour

POST /api/phone-verification/check (public: check)

Body: { phone, code, purpose }. Calls Twilio Verify verificationChecks.create. On approved: insert a phone_verifications row and return

{ "verified": true, "verificationId": "<uuid>", "accountExists": false }

accountExists = a row in public.users with this phone, phone_verified_at IS NOT NULL, deleted_at IS NULL (at most one, guaranteed by #843's partial unique index). Post-possession only: the caller just proved they own the phone, so learning their own phone has an account leaks nothing. There is deliberately no pre-verification lookup endpoint (account-enumeration oracle).

On wrong code: { verified: false } 200 (the shared OTP UI maps retry copy). Rate limit checks too: phone-otp-check:phone:<e164> 10 / hour (Twilio also caps attempts per verification at 5).

Service

PhoneVerificationService (resurrect the core from commit 676244049, unwound in #843): start(phone) / check(phone, code) against client.verify.v2.services(TWILIO_VERIFY_SERVICE_SID). Env: existing TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN (SmsService already uses them) plus TWILIO_VERIFY_SERVICE_SID (#843 provisions it; same service SID GoTrue uses). Test mode short-circuits like SmsService (isTestMode()), with a test-only accepted code so route tests run without Twilio.

Do not place it under /api/auth/* or name it anything identity-flavored; it proves possession for a transaction.

Comp flow integration (first consumer)

API side

  1. GET /api/comp/resources adds requiresVerifiedPhone to its offer payload (it already selects window/allowlists; the client currently has no way to know the offer requires a phone before hitting the 403).
  2. createCompBookingSchema adds optional phoneVerificationId.
  3. Gate widening in POST /api/comp/reservations (replaces the authed-only check at the requiresVerifiedPhone branch): pass if either
  4. authed caller with auth.users.phone_confirmed_at (existing behavior), or
  5. phoneVerificationId resolves to a row matching the payload phone (E.164-normalized) and purpose comp_booking, verifiedAt within 30 min, consumedAt IS NULL. Otherwise 403 PHONE_VERIFICATION_REQUIRED (code already exists in COMP_ERROR_CODES).
  6. Consumption in-tx: inside executeLogic, UPDATE ... SET consumed_at = now(), consumed_by = <redemption id> WHERE id = ? AND consumed_at IS NULL and throw if 0 rows (double-consume race).
  7. Per-phone cap in-tx, mirroring the existing per-user pattern (friendly precheck + P2002 catch): when the offer requiresVerifiedPhone, stamp PromoRedemption.phone with the verified E.164, guest and authed paths (authed phone read from public.users.phone). Existing redemption for (offerId, phone) → 409 ALREADY_REDEEMED (client already handles that code).

Web side

  1. Guest OTP transport: a third usePhoneOtp wrapper (usePhoneGuestOtp) whose sendOtp/verifyCode hit the two endpoints above. PhoneOtpPhoneStep/PhoneOtpCodeStep are presentational and reused as-is.
  2. CompDetailsForm: when the offer (from the extended /comp/resources payload, threaded through HourlyBranch) requires a verified phone: phone field becomes required, and submit interposes a code step before the booking call (same confirm-after-submit shape as #843's profile phone edit). On verified, the comp checkout fires automatically with phoneVerificationId.
  3. Member routing: if the check returns accountExists, show an interstitial before booking: "This number is verified on an existing account. Sign in to book with it." Actions: Sign in/auth/phone-login?returnTo=<current /book URL> (#880's URL-native state resumes the flow authed; the comp route accepts authed callers and the gate passes via their phone_confirmed_at), or Use a different number (back to the details step). ~~Continue as guest~~ — removed 2026-07-14 (Aaron): an account-owned phone must book on the owning account. The comp endpoint enforces this server-side with 409 PHONE_ACCOUNT_MISMATCH whenever the gate phone is verified on a live account other than the booking user (covers signed-in members proving possession of someone else's number too; the interstitial is client-side UX over that rule). Known wart, accepted for v1: the sign-in path sends a second OTP (login code after the possession code).
  4. useHourlyCheckout: comp params carry phoneVerificationId; map 403 PHONE_VERIFICATION_REQUIRED to a distinct outcome kind (currently falls through to the generic error) as the belt-and-suspenders path for stale clients or mid-session flag flips.

Unchanged. The OTP SMS is transactional (no consent needed); the marketing consent checkbox and sourceDetail segmentation from #880 stay as they are. A verified number makes the consent row more trustworthy; it doesn't change its collection.

Abuse and cost posture

The send endpoint spends money per call (Twilio Verify ≈ $0.05/successful verification + SMS fees; re-check pricing at implementation). Required at activation, not optional: the DB rate limits above, Twilio Fraud Guard enabled, and geo permissions restricted to US/CA on the Verify service. Same-service-SID sharing with GoTrue means a code from either path checks against either verifier within Twilio's window; both prove the same possession, accepted (ADR-024).

Activation order

  1. Merge after #843 and #880 (hard deps: PhoneOtpSteps/usePhoneOtp, phone login, public_rate_limits, comp surfaces, TWILIO_VERIFY_SERVICE_SID).
  2. Deploy migrations; set TWILIO_VERIFY_SERVICE_SID on Vercel mg-api (redeploy to apply); configure Fraud Guard + geo permissions in Twilio console.
  3. Verify end-to-end on a preview with a real phone (Twilio test creds can't exercise Verify meaningfully).
  4. Flip requiresVerifiedPhone on CHERRY_CITY_FIRST_PRACTICE via ops script (dry-run first). Independent of #878's promo activation; the offer works without the flag until then.

Rollback: flip the offer flag off. The endpoints are inert without a consumer.

Future consumers (explicitly deferred)

  • Paid guest checkout member detection: same check-then-offer-sign-in, no OTP requirement; needs only a purpose value and a mount in the guest details step.
  • Tour booking form: same shape if tour lead quality warrants it.
  • Guest booking management ("text me a code to view/reschedule my booking"): the possession primitive is the session mechanism; pairs with the post-#859 guest story.
  • Account claim prefill: claim flow already collects phone via /auth/complete-profile (identity-level, GoTrue); prefilling the possession-verified number is a UX nicety.