Guest self-service

Set or clear a customer's marketing consent from the link in a marketing email

post/api/unsubscribe/{token}

The write half of the unsubscribe surface (migration 0143); the page at /unsubscribe/{token} only renders, this route is the only thing that mutates. POST only, form-encoded, never JSON: read via request.formData(), which parses both multipart/form-data and application/x-www-form-urlencoded transparently. That matters for RFC 8058: a mail client rendering a native "Unsubscribe" button POSTs exactly List-Unsubscribe=One-Click as application/x-www-form-urlencoded, and this route reads it with the same parser the guest-facing form on the page uses, so there is one code path for both a mail client's automated click and a guest's real one.

token is book_customers.unsubscribe_token, a per-customer-per-company credential (not global): a person who books at two venues has two separate tokens and unsubscribing at one never touches the other. No session; the token alone resolves who this is, same posture as /api/feedback/{token}.

An unrecognised or empty body defaults to unsubscribe, never resubscribe: fail-safe toward less contact, not more. A stopSms=1 field alongside an unsubscribe additionally sets sms_opt_out_at; it has no effect on a resubscribe, since SMS consent is its own axis and a resubscribe must never silently reinstate it.

Path Parameters

token*string

The customer's unsubscribe token (a uuid); see unsubscribeUrl() in lib/marketing/consent.ts.

Request Body

multipart/form-data

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

curl -X POST "https://example.com/api/unsubscribe/string"
{  "ok": true}

Submit the private half of a booking's post-visit feedback POST

The other branch of the post-visit satisfaction gate a completed booking is emailed (see notifyBookingCompleted/notifyReservationCompleted): a guest who says a visit was NOT great lands here instead of the org's public Google review link, so the negative signal still reaches the business without ever becoming a public review. No slug, unlike the manage-link endpoint; the `manage_token` is unique across both engines by construction and is the whole credential, so it resolves a booking on its own. Guest name/email are read from the booking's own customer record rather than trusted from the request body, since the token already proves who this is. PUT-like replace semantics on a POST: a guest reopening the link edits their existing answer rather than stacking a second row, the same "one response per booking" convenience the intake form gives.

Log a guest's thumb-up tap and redirect to the org's Google review link GET

The other branch of the post-visit satisfaction gate: a GET, not a POST, because this is the link inside the confirmation/reminder email itself (`review-email.ts`'s `positiveReviewUrl`); a guest clicks it exactly like any other mailto/href, never sees this route, and is never shown a page belonging to it. Logs a bare click (`rating: 'positive'`, no body, no ratings; migration 0073's own constraint enforces that shape) then redirects straight to Google. Same credential model as the sibling POST route: the manage token alone resolves the booking, no session. **Logging is best-effort and never blocks the redirect.** A DB error or a rate-limit hit is swallowed; the guest always lands on Google. The click log itself exists for a later Google Business Profile connector to match a new public review back to a booking by guest name or time-proximity, not something worth degrading a happy guest's one tap over. Re-opening the link edits the existing row's `created_at` rather than stacking a second one (0073's partial unique index), which also keeps a later time-proximity match pointed at the most recent tap.