Completion runtime
The completion runtime is the public, respondent-facing survey page served at /s/:surveyId (frontend/src/pages/SurveyCompletionPage.tsx). It reads a published survey, renders it page by page, persists progress as the respondent advances, and finalises on submit. It talks only to the unauthenticated /public/surveys/* endpoints — there is no tenant session.
Loading the survey
The page first reads the published survey via GET /public/surveys/:id (api/src/routes/badges.ts), which returns the renderable survey or 404 if it isn't available. That endpoint sets cache-control: public, max-age=60. The owning tenant is never exposed to the client; capture resolves it server-side from the survey id.
The request body
Both capture endpoints share the captureResponseBody contract (packages/contracts/src/response.ts), with saveProgressBody adding a complete flag:
const captureResponseBody = z.object({
channel: z.enum(CHANNEL),
answers: z.array(answerInput).optional(),
customData: z.record(z.string(), z.unknown()).optional(),
agentId: z.string().uuid().optional(), // colleague attribution
idempotencyKey: z.string().min(1).max(200),
});
const saveProgressBody = captureResponseBody.extend({ complete: z.boolean().default(false) });
The runtime generates one idempotencyKey (a crypto.randomUUID()) and reuses it for the whole session, so every per-page save and the final submit address the same response.transaction row.
?agent= attribution
Agent ("colleague") attribution rides in on the link query string and is read once on load:
const agentId = useMemo(() => {
const a = new URLSearchParams(window.location.search).get('agent');
return a ?? undefined;
}, []);
This is the ?agent=<public-id> placed on the link by DispatchService (email click redirect, SMS link, ADEPT web link). It is sent on every capture as agentId, and the repository resolves it to a tenant agent and stores it on the response — which is what drives agent KPIs and badge evaluation. See the capture pipeline.
cd.* custom data capture
Any cd.<key>=<value> query parameters (ADEPT survey options / call context) are collected into the response's customData, with the cd. prefix stripped:
const linkCustomData = useMemo(() => {
const out: Record<string, string> = {};
for (const [k, v] of new URLSearchParams(window.location.search))
if (k.startsWith('cd.')) out[k.slice(3)] = v;
return out;
}, []);
This is the other half of the cd.<key> mechanism that DispatchService and the email click redirect place on the link — the survey options an agent-initiated send carries through to the captured response.
Per-page partial persistence
As the respondent moves between pages the runtime persists a partial (best-effort — a failed save must never block navigation) by POSTing saveProgress with complete: false:
// advancing a page → persist an in_flight partial
api.saveProgress(id, { channel: 'online', answers: collectAnswers(),
idempotencyKey, agentId, customData: linkCustomData, complete: false });
POST /public/surveys/:id/progress returns 202 { id, complete: false } for a partial. Because the same key is reused, each save upserts the one row and replaces its answer set — so progress survives an abandon, and a respondent who uses "Back" to edit an earlier page has their latest answers reflected. The server never downgrades a completed response back to in_flight.
Finalising
On submit the runtime posts once with complete: true:
await api.saveProgress(id, { channel: 'online', answers: collectAnswers(),
idempotencyKey, agentId, customData: linkCustomData, complete: true });
That returns 201 { id, complete: true }. The in_flight → complete transition is what fires the finalisation side-effects (alert/lead/badge) exactly once and enqueues ingestion for enrichment — all server-side, detailed in the capture pipeline.
(The simpler POST /public/surveys/:id/responses endpoint exists for single-shot capture — e.g. a one-page survey or an integration — and goes straight to a complete row. The page runtime uses the progress endpoint so abandons are preserved.)
Opt-out
A respondent can unsubscribe a contact for a survey's channel via POST /public/surveys/:id/opt-out ({ channel, address }). The address is hashed (hashContact) before storage, and the tenant is resolved from the survey. Subsequent invites to that contact are then gated by the compliance check described in Channels.