interface WebhookPayload { orderId: string; amount: number } compiles cleanly and the IDE is green. None of that helps when a webhook sender’s client library ships a breaking change and starts posting amount as a string, or orderId as null. TypeScript checked that your code handles a WebhookPayload correctly. It never checked that the JSON landing on your endpoint actually is one – it can’t. That check runs at compile time, and the payload doesn’t exist yet.
This article is about that gap. Not the general case for runtime validation, but the specific moment untrusted input – a webhook body, a form submission – crosses into your system, and what a schema library like Zod does at that line.
Why the Compiler Can’t Help You Here
TypeScript types are erased before your code runs. npx tsc strips every interface and type down to nothing, and the JavaScript that actually executes has no idea what shape anything is supposed to be. For values your own code constructs, that’s fine: a function that builds an object literal can’t lie about its own return type, because the compiler already checked the literal against the annotation. For a value your code receives from outside, it isn’t. await req.json() returns any (or unknown, on a strict enough config), and as WebhookPayload validates nothing – it tells the compiler to stop complaining. The payload underneath is still whatever the sender chose to send.
For a webhook, that ‘anything’ isn’t hypothetical. Vendors change payload shapes across API versions, retry a request with a partially different body after you’ve deployed against an older schema, or ship a bug on their end for a day. For a form, the ‘sender’ is a browser the user can open dev tools in. Client-side validation is UX, not a guarantee, and it doesn’t run at all when the request is replayed with curl.
Either way, the only code that ever runs against this payload is the one on your server, at the moment it arrives. If nothing checks the shape there, a malformed field gets caught wherever your code first tries to use it – usually several calls deep, as a TypeError: Cannot read properties of undefined, with no indication of which field in which request caused it.
Zod (and libraries like it – Valibot, io-ts, ArkType) moves that check to the boundary. One schema produces both a runtime validator and, via z.infer, the TypeScript type derived from it. The type and the check that enforces it can’t drift apart the way a hand-maintained interface and its manual validation function eventually do.
// generic pattern — illustrative, not any specific vendor's webhook shape
import { z } from "zod";
const webhookPayloadSchema = z.object({
event: z.enum(["order.created", "order.updated", "order.cancelled"]),
orderId: z.string().uuid(),
amount: z.number().positive(),
currency: z.enum(["EUR", "USD", "GBP"]),
occurredAt: z.string().datetime(), // ISO 8601
});
type WebhookPayload = z.infer<typeof webhookPayloadSchema>;
export async function POST(req: Request) {
const body = await req.json();
const result = webhookPayloadSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: "Invalid webhook payload", issues: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
await processOrderEvent(result.data); // typed as WebhookPayload, guaranteed to match
}result.data is WebhookPayload – not by assertion, but because safeParse just confirmed it. Everything downstream can stop defending itself against a malformed orderId or a missing amount. This line already ruled that out.
Shape Is Not the Same Guarantee as Semantics
z.object({ orderId: z.string() }) confirms orderId is a string. It says nothing about whether that string means anything. A payload with orderId: "banana" passes and fails three calls later, when nothing in your order table matches it. Two different checks:
- Shape validation – right keys, right types, right structure. Does this JSON look like the object your code expects to receive?
- Semantic validation – is the value valid in your domain? Is the VAT number a real VAT number? Is the date in the future? Is the enum value one that means something to your system? Is the amount plausible for this kind of order?
Zod’s chained validators enforce semantics in the same schema, not in a separate pass bolted on after:
// generic pattern — illustrative, not a specific production form
const invoiceFormSchema = z.object({
vatNumber: z
.string()
.regex(/^[A-Z]{2}\d{8,12}$/, "Must be a valid EU VAT number (e.g. FI12345678)"),
dueDate: z
.string()
.datetime()
.refine((val) => new Date(val) > new Date(), {
message: "Due date must be in the future",
}),
currency: z.enum(["EUR", "USD", "GBP"]),
lineItems: z
.array(z.object({ description: z.string().min(1), amount: z.number().positive() }))
.min(1, "At least one line item is required"),
});A payload can pass every shape check in that schema – every field present, every type correct – and still be garbage:
- a VAT number with the right pattern but a check-digit that doesn’t validate against the issuing country’s algorithm;
- a
dueDateof tomorrow for an invoice that should have been due last month; - an empty
lineItemsarray on a submission the UI should never have let through.
Shape validation alone passes all three, because ‘structurally valid’ and ‘actually correct’ are different claims. .regex(), .refine() and .min() are where the second one gets checked, and they cost nothing extra once you’re already parsing with Zod. The only discipline involved is remembering that a passing shape check isn’t the finish line.
What a Validation Failure Should Actually Tell Someone
A 400 Bad Request with no body tells the caller one thing: something was wrong. It doesn’t say what. For anyone debugging the integration – a partner’s engineer wiring up a webhook sender, your own support team triaging a form submission that silently failed – that’s a five-minute fix turned into an afternoon of guessing.
Zod’s safeParse doesn’t just return pass/fail. On failure, result.error carries a structured issue list: one entry per failed field, each with a path (which field, including nested paths like lineItems[2].amount) and a message (why). .flatten() reshapes that into a { field: [messages] } object you can return straight out of an API route or feed into a form’s field-level error state:
const result = invoiceFormSchema.safeParse(formData);
if (!result.success) {
const { fieldErrors } = result.error.flatten();
// { vatNumber: ["Must be a valid EU VAT number (e.g. FI12345678)"],
// dueDate: ["Due date must be in the future"] }
return Response.json({ error: "Validation failed", fieldErrors }, { status: 400 });
}On the caller’s side, a webhook sender’s engineer reading vatNumber: ["Must be a valid EU VAT number (e.g. FI12345678)"] fixes their integration in one pass. Without it, they file a ticket that starts with ‘your endpoint returns 400 and we don’t know why.’ Internally, a support person can see which field a customer’s submission failed on and explain it, instead of escalating ‘the form is broken’ to engineering with nothing attached. The generic 400 saves you four lines of code and costs everyone downstream an investigation they shouldn’t have had to run.
The same applies to a webhook whose retry behavior you don’t control. A 400 with a clear field-level reason is diagnostic information the vendor’s dashboard or logs can surface to whoever operates that integration. A 500 or a silent drop looks identical to your server being broken, and a sender that can’t tell ‘you rejected this payload’ from ‘your endpoint is down’ will often keep retrying the same doomed payload – the failure-classification problem I covered from the outbound-call side in Wiring an Accounting System into a Payment Webhook Without Losing Money. Validating shape and semantics comes before that article’s idempotency and retry handling even become relevant: a payload that fails validation gets rejected outright, not queued, retried, or partially processed.
A Different Boundary, Not the Same One
This site’s own content pipeline already runs Zod-derived validation: Velite’s s.* schema helpers, a thin wrapper over Zod, check every blog post’s and project’s frontmatter before the site builds. That’s a real, working example – it catches a malformed date or a missing required field before anything ships.
It is a different boundary from the one this article covers. It validates trusted content, written by the person running the build, once, at build time – not untrusted input arriving from outside, on every request, at runtime. Bad frontmatter fails a next build locally, where you fix it before it ships. A bad webhook payload arrives in production, from a sender you don’t control, on a schedule you don’t control, and has to be handled correctly right then or not at all. A Typed MDX Content Pipeline with Velite covers the build-time side in full – schema shapes, computed fields, codegen. This is the runtime counterpart: same idea (the schema is the single source of truth for what’s valid), different stakes and a different failure mode.
Where This Pays Off and Where It’s Overkill
Runtime validation pays for itself the moment input crosses a trust boundary you don’t fully control: any webhook, any public form, any request from a client whose release cycle isn’t yours. The alternative there isn’t ‘less code.’ It’s the same checks scattered across whatever functions happen to touch the field first – inconsistent, undocumented, and discovered one TypeError at a time in production.
It’s overkill for values your own code produces and consumes in the same process. An internal function’s return value doesn’t need a Zod schema between the return and the call site; the type system covers it, because nothing external touched it in between.
Don’t over-specify either. A .refine() encoding a business rule that will change – an exact currency list, a hardcoded date cutoff – belongs somewhere easier to update than a schema buried in a route handler, or it should read from configuration instead of being inlined as a literal.
The boundary is the cheapest place to catch a malformed payload – before business logic, before the queue, before three functions downstream have half-processed it. If you’re wiring a webhook or a public form endpoint and want that boundary built with validation that tells the caller what actually went wrong, get in touch – that’s the kind of API integration work I do.
Further reading:
- Zod documentation
- A Typed MDX Content Pipeline with Velite – the build-time counterpart: validating trusted content once, not untrusted requests on every call
- Stripe Webhooks: Idempotency, Retries, and Queue Setup – what happens after a payload passes validation: signature checks, dedup, and queueing
- Wiring an Accounting System into a Payment Webhook Without Losing Money – the failure-classification problem downstream of validation, for outbound calls
- Idempotency Keys: Building Retries That Don’t Double-Charge – the retry-safety layer that sits right after validation in a webhook handler








