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)
INSERTintocontacts(consent flags included).- Best-effort nurture enrollment (
on_lead) — idempotent per (instance, contact, sequence). - Best-effort owner notification email to
instances.notify_email("New lead — call now"), logged tooutbound_messages. - 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
instanceIdor DB failure returns500 { "ok": false, "error": ... }— the lead was not captured; retry."mode": "demo"/"contactId": "demo"now appear only on a keyless deployment. Content-Lengthis 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_registrationson the uniqueemailcolumn; 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 200text/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 noretry-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