API overview
The platform is a single Hono application running on Cloudflare Workers, defined in api/src/app.ts. There is one app instance; everything is composed onto it as middleware and mounted route groups. The same app serves three distinct surfaces:
/v1/*— the tenant-scoped API consumed by the SPA and by integration API keys. Every route is authenticated and tenant-pinned./public/*and/r/:slug— unauthenticated surfaces: respondent survey capture, badges, tracking pixels/redirects, provider webhooks (Resend, Twilio), and short-URL resolution. The owning tenant is always resolved server-side (from the survey id, the open conversation, or the token), never from client input./health,/health/ready— liveness and deep-readiness probes.
Request lifecycle
Two global middlewares wrap every request, registered with app.use('*', …):
- Container injection.
buildContainer(c.env)constructs the provider container (thePorts— database, email, sms, voice, ai, queue, identity) and sets it onc.var.ports. Adapters are built lazily, so a no-provider route like/healthconstructs nothing. After the response, the request's Postgres connection is disposed viac.executionCtx.waitUntil(ports.dispose())— Workers cannot reuse a connection's I/O across requests. - CORS. Origins in
CORS_ORIGINS(comma-separated) are reflected; anylocalhost/127.0.0.1origin is additionally allowed sovite dev/vite previewwork. Auth is a bearer token (no cookies), so credentials are not used. Allowed headers areauthorizationandcontent-type.
app.use('*', async (c, next) => {
const ports = buildContainer(c.env);
c.set('ports', ports);
try { await next(); }
finally { if (ports.dispose) c.executionCtx.waitUntil(ports.dispose()); }
});
Route groups
/v1 is composed from many domain route files, each a Hono sub-app mounted with app.route('/v1', …):
| Group | File | Example paths |
|---|---|---|
| Surveys, responses, reporting, dispatch | routes/surveys.ts | /v1/surveys, /v1/responses/:rid, /v1/surveys/:id/invite |
| Alerts | routes/alerts.ts | /v1/alerts |
| Leads | routes/leads.ts | /v1/leads |
| Admin (agents, webhooks, segments, badges, audit) | routes/admin.ts | /v1/webhooks, /v1/agents |
| API keys | routes/api-keys.ts | /v1/api-keys |
| Roles & permissions | routes/roles.ts | /v1/roles |
| Compliance / GDPR | routes/compliance.ts, routes/gdpr.ts | /v1/suppression, /v1/gdpr |
| Email templates, short URLs, inbound numbers | routes/email-templates.ts, routes/short-urls.ts, routes/inbound-numbers.ts | /v1/short-urls |
| Dashboards, reports, verbatim, filter views | routes/dashboards.ts, routes/reports.ts, routes/verbatim.ts, routes/filter-views.ts | /v1/dashboards |
| Durable exports | routes/exports.ts | /v1/export-templates, /v1/export-schedules, /v1/export-runs |
| ADEPT, proxy widget, org, system | routes/adept.ts, routes/proxy.ts, routes/org.ts, routes/system.ts | /v1/proxy/fetch |
Durable export creation returns 202 with an immutable run. Queue/Cron advances bounded build,
manifest, delivery, cancellation, expiry, and verified-cleanup phases; authenticated status, manifest,
and part routes never expose an R2 key or credential. The retired synchronous
GET /v1/surveys/:id/export route returns 410 with migration guidance.
Public surfaces are mounted separately:
app.route('/public', publicBadgeRoutes); // capture, badges, /track, /email/events, /embed.js
app.route('/public', twilioRoutes); // /public/twilio/sms, /public/twilio/voice/:surveyId
app.get('/r/:slug', …); // short-URL 302 redirect
app.get('/public/signups', …); // feature-flag the SPA reads before workspace creation
/v1 vs /public
The split is about who establishes the tenant. /v1 requires an authenticated principal (a session or an API key) and pins c.var.tenant from it. /public routes have no principal at all — they resolve the tenant from a server-trusted identifier and never read tenant from a header or body. See Authentication for how requireTenant works and the public API overview for the integration-facing surfaces.
Middleware chain on /v1
Each /v1 request passes through, in order:
app.use('/v1/*', requireTenant); // session/API-key → c.var.tenant
app.use('/v1/*', ipAllowlist); // optional per-tenant IP allowlist
app.use('/v1/*', apiKeyRateLimit({ limit: 600, windowMs: 60_000 })); // per-API-key throttle
Within a route, tenant mutations are additionally gated by a permission check — can(c.var.tenant, perm) returning c.json(forbidden(perm), 403) when missing. Reads are generally available to any tenant member (RLS still isolates the tenant). See Authentication, Rate limiting, and Errors & validation.
Health
GET /health returns { ok, service, version, time } (validated against the healthResponse contract). GET /health/ready calls ports.db.ping() and fails closed with 503 { ok:false, db:'down' } when the database is unreachable, so a probe pulls the instance rather than serving errors.