All Docs
FeaturesNurtureHubUpdated March 25, 2026

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 all parse* 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:

  • choices must be a non-empty array.
  • Each choice must contain a message object.
  • message.content may be null (tool-call responses) — callers should use ?? "".
  • usage fields default to 0 if 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

EndpointHelper
POST /token (OAuth)parseReapitTokenResponse()
GET /contactsparseReapitContactsPage()

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

EndpointHelper
GET /contactsparseAgentOSContactsPage()
GET /applicantsparseAgentOSApplicantsPage()
GET /contacts/:id/activitiesparseAgentOSActivitiesPage()
GET /activitiesparseAgentOSActivitiesPage()

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

  1. Define a Zod schema in src/lib/api-validation/schemas.ts.
  2. Export a parse* helper that calls parseApiResponse() with the provider name and endpoint string.
  3. Replace the response.json() as SomeType cast in the adapter with a call to your new helper.
  4. 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.