How We Validate External API Responses at Runtime
How We Validate External API Responses at Runtime
Release: v1.0.440 · Control: SCR-05 · Category: API Connection / Supply Chain Security
This platform connects to three external APIs — HMRC, TrueLayer (bank feeds), and AgentOS (property management). Every network response those services return is now validated at runtime using Zod before any application code touches the data.
The Problem With TypeScript Casts Alone
TypeScript's type system is erased at compile time. A pattern like:
const data = JSON.parse(responseBody) as HmrcBusinessDetailsResponse;
…gives you no runtime protection whatsoever. If HMRC adds, removes, or renames a field, the cast succeeds silently and the malformed object propagates through the application. For a tax compliance platform, that means potentially incorrect quarterly figures or failed HMRC submissions — both of which have real consequences for users.
The Solution: Zod Schemas at API Boundaries
Every response from an external API is now parsed through a Zod schema immediately after JSON.parse(). If the shape of the response does not match the schema, a structured error is thrown before the data reaches any business logic.
Pattern
import { z } from 'zod';
const HmrcTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
token_type: z.string(),
});
type HmrcTokenResponse = z.infer<typeof HmrcTokenResponseSchema>;
// Inside the client:
const raw = JSON.parse(responseBody);
const data = HmrcTokenResponseSchema.parse(raw); // throws on mismatch
Using z.infer<typeof Schema> means the TypeScript type and the runtime validator are always derived from the same source of truth — there is no opportunity for the type and the schema to drift apart.
Schemas Introduced in v1.0.440
HMRC (src/lib/hmrc/client.ts)
| Schema | Purpose |
|---|---|
HmrcTokenResponseSchema | OAuth token exchange and refresh responses |
HmrcBusinessDetailsResponseSchema | listOfBusinesses endpoint — identifies business IDs used in submissions |
HmrcObligationsResponseSchema | Quarterly and annual obligation periods |
TrueLayer
| Schema | Purpose |
|---|---|
TrueLayerTokensSchema | OAuth token responses from TrueLayer |
TrueLayerAccountSchema | Bank account metadata |
TrueLayerTransactionSchema | Individual transaction records imported for categorisation |
TrueLayerBalanceSchema | Account balance data |
AgentOS
All AgentOS response payloads are validated against defined Zod schemas before property or tenancy data is used by the application.
Error Behaviour
When a schema validation fails, the client throws a structured error containing:
- The name of the schema that failed
- The Zod validation error details (which fields were missing, wrong type, etc.)
- The originating API (HMRC / TrueLayer / AgentOS)
This means failures are immediately visible in logs and error monitoring — there is no silent processing of malformed data.
// Example structured error shape
{
source: 'hmrc',
schema: 'HmrcBusinessDetailsResponse',
issues: [
{ path: ['listOfBusinesses', 0, 'businessId'], message: 'Required' }
]
}
Why HMRC Responses Are the Highest Priority
The listOfBusinesses response from HMRC contains the business IDs used in every subsequent quarterly submission and final declaration. If this response were malformed and processed without validation, downstream submissions could reference incorrect or missing IDs — resulting in failed or incorrect MTD ITSA submissions. Zod validation here acts as a hard stop before any submission data is written.
Using safeParse for Non-Critical Paths
For paths where a validation failure should degrade gracefully rather than throw, use safeParse:
const result = TrueLayerTransactionSchema.safeParse(raw);
if (!result.success) {
logger.warn('Unexpected transaction shape', { issues: result.error.issues });
return null; // skip this transaction rather than crash
}
const transaction = result.data;
Summary
| Before v1.0.440 | From v1.0.440 |
|---|---|
JSON.parse() → TypeScript cast | JSON.parse() → schema.parse() → typed value |
| No runtime shape check | Full field-level validation at the API boundary |
| Malformed data propagates silently | Structured error thrown immediately on mismatch |
| Types can drift from actual API responses | Types derived from schemas — always in sync |