Skip to main content

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('*', …):

  1. Container injection. buildContainer(c.env) constructs the provider container (the Ports — database, email, sms, voice, ai, queue, identity) and sets it on c.var.ports. Adapters are built lazily, so a no-provider route like /health constructs nothing. After the response, the request's Postgres connection is disposed via c.executionCtx.waitUntil(ports.dispose()) — Workers cannot reuse a connection's I/O across requests.
  2. CORS. Origins in CORS_ORIGINS (comma-separated) are reflected; any localhost/127.0.0.1 origin is additionally allowed so vite dev/vite preview work. Auth is a bearer token (no cookies), so credentials are not used. Allowed headers are authorization and content-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', …):

GroupFileExample paths
Surveys, responses, reporting, dispatchroutes/surveys.ts/v1/surveys, /v1/responses/:rid, /v1/surveys/:id/invite
Alertsroutes/alerts.ts/v1/alerts
Leadsroutes/leads.ts/v1/leads
Admin (agents, webhooks, segments, badges, audit)routes/admin.ts/v1/webhooks, /v1/agents
API keysroutes/api-keys.ts/v1/api-keys
Roles & permissionsroutes/roles.ts/v1/roles
Compliance / GDPRroutes/compliance.ts, routes/gdpr.ts/v1/suppression, /v1/gdpr
Email templates, short URLs, inbound numbersroutes/email-templates.ts, routes/short-urls.ts, routes/inbound-numbers.ts/v1/short-urls
Dashboards, reports, verbatim, filter viewsroutes/dashboards.ts, routes/reports.ts, routes/verbatim.ts, routes/filter-views.ts/v1/dashboards
Durable exportsroutes/exports.ts/v1/export-templates, /v1/export-schedules, /v1/export-runs
ADEPT, proxy widget, org, systemroutes/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.