Project One

Leads & Marketing Intake

Public endpoints that feed the funnel: lead capture from published landing pages, pre-launch interest registration (the tour gate), and CAN-SPAM/RFC 8058 unsubscribe.

Endpoint Auth Rate limit Body cap
POST /api/leads public 20/min per IP 1 MB
POST /api/interest public 10/10 min per IP 1 MB
GET /api/unsubscribe HMAC token in URL 30/min per IP (shared with POST) —
POST /api/unsubscribe HMAC token in URL shared bucket with GET —

POST /api/leads

Captures a lead into the instance's own CRM (contacts), enrolls the on_lead nurture sequence, and fires the speed-to-lead owner email.

Request

JSON or form-encoded. Every string is trimmed and capped at 500 chars.

Field Type Required Notes
instanceId (alias instance_id) string yes which tenant this lead belongs to
email string yes lowercased; must match ^[^@\s]+@[^@\s]+\.[^@\s]+$
name string no
phone string no
source string no one of page ad manual import; anything else silently becomes page
emailConsent (alias email_consent) bool-ish no truthy for true/"true"/"on"/"1"; defaults to true when both aliases are absent (form submission implies opt-in)
smsConsent (alias sms_consent) bool-ish no same truthiness; defaults to false — SMS never defaults on

Responses

Status Body
200 (live) { "ok": true, "contactId": "<uuid>", "message": "Thanks — you're on the list. Check your email to confirm your time." }
200 (demo, keyless only) { "ok": true, "contactId": "demo", "message": "Thanks — you're on the list.", "mode": "demo" }
400 { "ok": false, "missing": ["instanceId" | "email", ...] } (a regex-failing email lands in missing)
500 (live failure) { "ok": false, "error": "That didn’t go through — please try again in a moment." } — e.g. unknown instanceId, DB failure (API slice 0)
411 / 413 / 429 shared envelopes — see conventions §4-5

Side effects (live)

  1. INSERT into contacts (consent flags included).
  2. Best-effort nurture enrollment (on_lead) — idempotent per (instance, contact, sequence).
  3. Best-effort owner notification email to instances.notify_email ("New lead — call now"), logged to outbound_messages.
  4. Telemetry lead_captured — features only (source, has_phone, consent booleans), never the lead's details.

Notes for callers

  • Not idempotent: every valid POST inserts a new contact row (no unique-email constraint). Only the nurture enrollment dedupes.
  • Live failures are honest 500s (API slice 0 reversed the old fail-open contract): an unknown instanceId or DB failure returns 500 { "ok": false, "error": ... } — the lead was not captured; retry. "mode": "demo" / "contactId": "demo" now appear only on a keyless deployment.
  • Content-Length is required (chunked bodies get 411).

Source: app/api/leads/route.ts, src/lib/comms/leads.ts, src/lib/live/leads.ts, src/lib/comms/notify.ts


POST /api/interest

Pre-revenue expression of interest (name + email before the guided tour). Successful responses set the tour cookie that opens the walkthrough.

Request

JSON or form-encoded:

Field Type Required Notes
name string ≤120 no
email string ≤200 yes validated, not lowercased
business string ≤160 no
consent boolean no true unless literally false/"false"

Responses

Status Body
200 (live) { "ok": true, "message": "You're on the list — enjoy the tour." }
200 (demo) same + "mode": "demo"
400 { "ok": false, "error": "That doesn't look like an email address." }
500 (live failure) { "ok": false, "error": "That didn’t go through — please try again in a moment." } — the registration did not persist (API slice 0)
411 / 413 / 429 shared envelopes

Both 200s set: set-cookie: p1_tour=1; Path=/; Max-Age=31536000; SameSite=Lax; Secure (deliberately not httpOnly — the tour gate reads it client-side).

Notes for callers

  • Idempotent by email: live path upserts interest_registrations on the unique email column; re-registering refreshes the row.
  • Demo mode stores nothing but still sets the cookie (the tour opens either way).

Source: app/api/interest/route.ts, src/lib/live/interest.ts


GET / POST /api/unsubscribe

GET is the human-clicked link from every nurture email; POST is the RFC 8058 one-click target mail clients hit automatically. Both verify the same stateless HMAC token and stamp contacts.unsubscribed_at.

Token

?token=<base64url("contactId:instanceId:sig")> where sig = HMAC-SHA256("contactId:instanceId", UNSUBSCRIBE_TOKEN_SECRET). Bound to both contact and instance; verified with a timing-safe compare. Reusable and non-expiring by design — re-clicks are idempotent re-stamps.

Responses

  • GET: every non-429 outcome is 200 text/html — success, invalid token, live error, and demo differ only in page copy (deliberate anti-enumeration; do not parse GET status programmatically).
  • POST: identical processing, then the body is dropped — all page outcomes map to 204; only 429 passes through (with no body and no retry-after, unlike other routes).

Notes for callers

  • 204 ≠ persisted: an invalid token or live DB failure also yields 204 on the POST path. This endpoint is built for mail clients, not for programmatic state confirmation.
  • A structurally valid token whose contact no longer exists still shows the success page (zero-row update is not an error).

Source: app/api/unsubscribe/route.ts, src/lib/comms/unsubscribe.ts