An unhandled webhook payload will absolutely wreck your Express server or corrupt your database. I hit this exact wall last week: a third-party CRM API quietly updated its payload structure without bumping its version. They started sending null instead of an empty array. My app crashed instantly because a downstream function tried to .map() over a non-existent list. A simple Zod schema at the gateway would’ve caught this, thrown a clean 400, and kept the server alive. Let’s build a type-safe validation layer to make sure this never happens to your production app.

Setting up the Express and Zod project
Let’s spin up a minimal TypeScript and Express project. We’ll need Express, Zod, and a few dev dependencies to run TypeScript directly without a tedious build step. Run this in your terminal to get started:
mkdir express-zod-webhooks
cd express-zod-webhooks
npm init -y
npm install express zod
npm install --save-dev typescript @types/node @types/express tsx
Next, drop a basic tsconfig.json in your root directory to configure the TypeScript compiler:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }
}
Now we can set up our entry point. Create a server.ts file and throw in a basic Express server setup:
import express from 'express'; const app = express();
app.use(express.json()); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Why basic object validation falls short
You can never trust req.body when dealing with webhooks. The easiest temptation is to just write quick inline checks to make sure fields exist before you touch them. Here’s what most of us do when we’re in a rush:
app.post('/webhooks/user-signup', (req, res) => { const { email, id, profile } = req.body; if (!email || !id || !profile || !profile.firstName) { return res.status(400).send('Missing required fields'); } console.log(`Processing signup for ${email}`); res.sendStatus(200);
});
This looks fine for three fields, but it scales horribly. It doesn’t validate types (someone could send an integer as an email and your code would happily accept it), it chokes on nested objects, and you end up writing the same boilerplate for every single endpoint. If you’re trying to secure GoHighLevel webhook listener endpoints or process Stripe events, manual checks will turn your codebase into spaghetti in no time.
Building the Zod schema for webhooks
Instead of writing manual checks, we can define a strict schema using Zod. Think of this schema as a strict contract. If the incoming payload doesn’t match, we reject it immediately. Let’s build a schema for a typical user signup webhook. Create a schemas.ts file and drop this in:
import { z } from 'zod'; export const UserSignupSchema = z.object({ event: z.literal('user.signup'), timestamp: z.string().datetime(), data: z.object({ userId: z.string().uuid(), email: z.string().email(), plan: z.enum(['free', 'pro', 'enterprise']), metadata: z.record(z.string()).optional() })
}); export type UserSignupPayload = z.infer<typeof UserSignupSchema>;
This schema guarantees our data structure before the route handler even touches it. The event must be exactly "user.signup", timestamp has to be a valid ISO 8601 string, userId must be a UUID, email must actually look like an email, and the plan is strictly locked to our three options.
Creating the validation middleware
Next, we need a reusable Express middleware. It should take a Zod schema, validate req.body against it, and return a clean error response if things look wrong. Create a middleware.ts file and add this:
import { Request, Response, NextFunction } from 'express'; import { ZodSchema, ZodError } from 'zod'; export const validateBody = (schema: ZodSchema) => { return async (req: Request, res: Response, next: NextFunction) => { const result = await schema.safeParseAsync(req.body); if (!result.success) { return res.status(400).json({ error: 'Validation failed', details: formatZodErrors(result.error) }); } req.body = result.data; next(); };
}; function formatZodErrors(error: ZodError) { return error.issues.map(issue => ({ field: issue.path.join('.'), message: issue.message }));
}
I used safeParseAsync here instead of parseAsync because we don’t want Zod throwing unhandled exceptions and crashing the thread. Instead, it returns an object with either success: true (and the parsed data) or success: false (with the validation errors). Notice that we reassign req.body = result.data. This is because it ensures any default values or type coercions we define in Zod actually get passed down to our route handler.
Plugging the middleware into Express
With the schema and middleware ready, let’s wire them into server.ts to protect our webhook route. Update your server.ts file:
import express from 'express';
import { validateBody } from './middleware.js';
import { UserSignupSchema } from './schemas.js'; const app = express();
app.use(express.json()); app.post('/webhooks/signup', validateBody(UserSignupSchema), (req, res) => { const payload = req.body; console.log(`Successfully processed signup for user: ${payload.data.email}`); res.status(200).json({ received: true });
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Fire up the server in dev mode using tsx:
npx tsx watch server.ts
Testing with bad payloads
Let’s break it and see how it handles bad data. We’ll send a POST request with an invalid email, a bogus plan type, and a missing UUID. Open a new terminal and run this curl command:
curl -X POST http://localhost:3000/webhooks/signup
-H "Content-Type: application/json"
-d '{ "event": "user.signup", "timestamp": "2023-10-27T14:20:00Z", "data": { "userId": "not-a-uuid", "email": "bad-email-format", "plan": "premium" }
}'
Our middleware intercepts this before it ever touches our route logic. The server fires back a 400 Bad Request with a clear JSON payload showing exactly what failed:
{ "error": "Validation failed", "details": [ { "field": "data.userId", "message": "Invalid uuid" }, { "field": "data.email", "message": "Invalid email" }, { "field": "data.plan", "message": "Invalid enum value. Expected 'free' | 'pro' | 'enterprise', received 'premium'" } ]
}
This keeps garbage data out of your logs and database. Plus, if you’re building a webhook retry system with exponential backoff, returning a clean 400 bad request tells the sender immediately not to bother retrying a payload that’s fundamentally broken anyway.
Handling optional fields and coercion
Webhooks are notorious for omitting optional fields or sending numbers as strings. Zod lets us handle these edge cases without writing messy parsing logic. Let’s update our schema to handle an optional phone number and coerce an incoming string timestamp into a real JavaScript Date object:
import { z } from 'zod'; export const FlexibleWebhookSchema = z.object({ id: z.string(), processedAt: z.coerce.date(), phoneNumber: z.string().optional().nullable()
});
Using z.coerce.date() tells Zod to run the incoming value through the native Date constructor. If it’s a valid timestamp string, it lands in your req.body as a real Date object. The .optional().nullable() chain is a lifesaver—it means if the external service sends null, an empty string, or completely omits the key, the schema still passes without throwing a fit.
Securing the endpoint with signature verification
Validating the payload shape is only half the battle. You also have to make sure the request actually came from your provider and not some random script on the internet. This usually means verifying a cryptographic signature in the headers. Always verify signatures before parsing schemas. If you validate the schema first, you’re wasting CPU cycles parsing potentially malicious payloads. If you need to test this end-to-end locally, you can test webhooks locally with Cloudflare Tunnels to get real payload signatures. Here’s how you stack signature verification and Zod validation in Express:
import express from 'express';
import { validateBody } from './middleware.js';
import { UserSignupSchema } from './schemas.js';
import { verifySignature } from './crypto.js'; // Your custom signature check const app = express(); app.post( '/webhooks/signup', express.raw({ type: 'application/json' }), // Need raw body for signature verification verifySignature, express.json(), validateBody(UserSignupSchema), (req, res) => { res.sendStatus(200); }
);
This setup ensures you only spend resources parsing payloads that are cryptographically verified. If you’re building a highly resilient webhook architecture, you should pair this validation with a Redis-backed idempotency middleware to stop duplicate webhook events from processing twice.
Webhook Validation FAQ
Does Zod validation slow down Express?
For typical JSON payloads, the overhead is negligible—usually under 1ms. If you’re processing thousands of webhooks per second on a single tiny instance, you might notice a slight CPU bump. But the safety of preventing database corruption or unhandled crashes far outweighs any micro-optimization.
How do I extract TypeScript types from my Zod schemas?
Use Zod’s built-in z.infer utility. It lets you define your validation schema once and automatically extract the TypeScript types from it. This saves you from maintaining duplicate TypeScript interfaces and keeping them in sync manually.
Can I use Zod to validate query parameters too?
Absolutely. You can write a similar middleware that targets req.query or req.params instead of req.body. It’s incredibly useful for validating webhook callback URLs that pass state tokens or IDs in the query string.
How should I handle nested arrays in webhook payloads?
You can use z.array() to validate lists of nested objects. For example, if a billing webhook sends a list of line items, you’d define a LineItemSchema first, then include it in your main schema as lineItems: z.array(LineItemSchema).
Next steps
Now that your Express server safely filters out garbage payloads, you should think about how to handle webhooks when your downstream services go down. Check out our guide on how to build a webhook retry system with exponential backoff in Node.js to make sure you never drop a webhook event again.

