API Response Validation (SCR-05)
API Response Validation (SCR-05)
NurtureHub validates every external API response at runtime using Zod schemas before any data is consumed by the application. This prevents silent data corruption when a third-party provider changes their response shape.
Why Runtime Validation?
TypeScript type assertions (as SomeType) are erased at runtime. If OpenAI adds a required field, Reapit renames _embedded, or a CRM adapter receives an unexpected payload structure, TypeScript offers no protection — the failure either surfaces as a cryptic runtime exception deep in the nurture pipeline, or worse, passes silently and corrupts contact data.
Runtime validation with Zod catches these problems at the API boundary and produces structured, actionable error messages.
Architecture
All validation logic lives in a single module:
src/lib/api-validation/schemas.ts
The module exports:
parseApiResponse<T>(schema, data, provider, endpoint)— generic helper used by allparse*functions.ApiResponseValidationError— thrown on any validation failure.- Provider-specific
parse*helpers — one per provider endpoint.
ApiResponseValidationError
class ApiResponseValidationError extends Error {
provider: string; // e.g. "openai"
endpoint: string; // e.g. "POST /v1/chat/completions"
issues: z.ZodIssue[];
}
The error message includes up to three summarised Zod issues in the format:
[openai] Response validation failed for POST /v1/chat/completions: choices: Array must contain at least 1 element(s)
parseApiResponse<T>()
function parseApiResponse<TOutput>(
schema: z.ZodType<TOutput, any, any>,
data: unknown,
provider: string,
endpoint: string
): TOutput
Runs schema.safeParse(data). On failure, logs the full Zod issues array to the console and throws ApiResponseValidationError. On success, returns the typed, validated data.
Covered Providers
OpenAI — POST /v1/chat/completions
Validates that the choices array is present and contains at least one item before extracting content and token usage.
import { parseOpenAICompletion } from "@/lib/api-validation/schemas";
const data = parseOpenAICompletion(await response.json());
const content = data.choices[0]?.message?.content ?? "";
Validation rules:
choicesmust be a non-empty array.- Each choice must contain a
messageobject. message.contentmay benull(tool-call responses) — callers should use?? "".usagefields default to0if absent.
Error behaviour: ApiResponseValidationError is treated as non-retryable in the OpenAI client — a malformed response shape will not improve on retry.
Resend — POST /emails
Validates that the send response contains a non-empty id.
import { parseResendSendResponse } from "@/lib/api-validation/schemas";
const result = parseResendSendResponse(await response.json());
console.log(result.id); // confirmed non-empty
Error behaviour: Graceful degradation — validation failure logs a warning but does not block email delivery. The raw response is returned instead.
Reapit Foundation
| Endpoint | Helper |
|---|---|
POST /token (OAuth) | parseReapitTokenResponse() |
GET /contacts | parseReapitContactsPage() |
Token validation rules: access_token (non-empty string) and expires_in (number) must be present.
Contacts page validation rules: _embedded containing a contacts array must be present.
Alto — GET /api/people
Validates paged responses accepting either a data or items key for the contact array.
import { parseAltoContactsPage } from "@/lib/api-validation/schemas";
Loop — GET /api/v1/contacts
Validates paged responses accepting either a data or contacts key.
import { parseLoopContactsPage } from "@/lib/api-validation/schemas";
Street — GET /v1/contacts
Validates paged responses including meta pagination fields.
import { parseStreetContactsPage } from "@/lib/api-validation/schemas";
agentOS
| Endpoint | Helper |
|---|---|
GET /contacts | parseAgentOSContactsPage() |
GET /applicants | parseAgentOSApplicantsPage() |
GET /contacts/:id/activities | parseAgentOSActivitiesPage() |
GET /activities | parseAgentOSActivitiesPage() |
Passthrough for CRM Fields
All contact and item object schemas use Zod's .passthrough(). This means unknown fields returned by a CRM (custom properties, agency-specific extensions) are preserved rather than stripped, ensuring they remain available for storage in rawCrmPayload.
Adding a New Provider
- Define a Zod schema in
src/lib/api-validation/schemas.ts. - Export a
parse*helper that callsparseApiResponse()with the provider name and endpoint string. - Replace the
response.json() as SomeTypecast in the adapter with a call to your new helper. - Add Vitest tests covering the valid case, required-field-missing cases, and passthrough of unknown fields.
Testing
~40 Vitest tests are located in:
tests/lib/api-validation/schemas.test.ts
Each provider has tests for:
- A fully valid response → parses without error.
- One or more missing required fields → throws
ApiResponseValidationError. - Extra/unknown fields → preserved via passthrough.
- Forward-compatible enum/string values → accepted.