Errors & validation
The error envelope
Every non-2xx response is a single, uniform JSON envelope:
{ "error": { "code": "string", "message": "string" } }
Some validation responses additionally carry a details field (the Zod issue array). The envelope is produced centrally in app.onError (api/src/app.ts), so any error thrown anywhere in a handler is normalised:
app.onError((err) => {
if (err instanceof AppError) return envelope(err.code, err.message, err.status);
if (err instanceof Error && err.name === 'ZodError')
return envelope('validation_error', 'Invalid request', 400);
if (err instanceof Error && /invalid input syntax for type uuid/i.test(err.message))
return envelope('not_found', 'Not found', 404);
console.error('unhandled error', err);
return envelope('internal', 'Internal error', 500);
});
Mapping rules:
| Thrown value | Code | Status |
|---|---|---|
AppError (from @voc/core) | err.code | err.status |
ZodError (e.g. a bad pagination query reaching .parse()) | validation_error | 400 |
A malformed UUID id/cursor that reaches a uuid column (Postgres 22P02) | not_found | 404 |
| Anything else | internal | 500 (real error logged server-side, never echoed) |
app.notFound returns the same shape: 404 { error: { code: 'not_found', message: 'Not found' } }.
AppError is the idiomatic way for a domain service to signal a typed failure — e.g. throw new AppError('invalid phone number…', 'invalid_request', 400) in domains/dispatch.ts, or throw new AppError('IVR requires a public base URL', 'not_implemented', 501). The status and code flow straight through to the envelope.
Internal errors are logged server-side via console.error('unhandled error', err) and shipped to Sentry when SENTRY_DSN is configured. The error message is never returned to the client for a generic 500.
Validation with @voc/contracts
Request bodies and query strings are validated against Zod schemas exported from the @voc/contracts package. Two patterns are used in routes:
safeParse with an explicit 400 — the common case for request bodies. The handler returns a contract-specific message and the issue list in details:
const parsed = createApiKeyBody.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) {
return c.json({ error: { code: 'invalid_request', message: 'invalid api key body' } }, 400);
}
// parsed.data is fully typed
Note: .catch(() => null) guards against a non-JSON body — null simply fails the parse and yields a clean 400 rather than a thrown SyntaxError.
.parse() relying on onError — used for query parsing where a throw is acceptable. For example the shared page(url) helper in routes/surveys.ts calls pageQuery.parse(...); a bad limit/cursor throws a ZodError that onError maps to 400 validation_error.
const page = (url: string) => {
const q = pageQuery.parse(Object.fromEntries(new URL(url).searchParams));
return { limit: q.limit, cursor: q.cursor };
};
Contracts are the single source of truth for request/response shapes shared between the API and the SPA. Representative schemas: createSurveyBody, captureResponseBody, saveProgressBody, inviteBody, bulkInviteBody, fileInviteBody, createApiKeyBody, createWebhookBody, shortUrlBody, pageQuery. Pagination is cursor-based (keyset): list endpoints return { items, cursor } and accept limit + cursor query params.
Conventions
- Success bodies are domain-specific and unwrapped:
{ id }on creates (HTTP 201),{ items, cursor }on lists,{ ok: true }on side-effecting actions. - Public capture endpoints return
202for a persisted partial and201for a finalised response (see the capture pipeline). - A
429from the rate limiter carries the same envelope plus aRetry-Afterheader — see Rate limiting.