Validating webhook payloads with JSON Schema at the receiver boundary
A webhook receiver is a public HTTP endpoint that accepts JSON written by somebody else’s release train. The contract you agreed on in event schema design only holds if something checks it on every request, which is why the boundary handler — not the order service three calls downstream — is where JSON Schema belongs. This page implements that check in TypeScript with Ajv, and assumes the schemas themselves are governed by a schema registry for webhook events or a versioned directory in the receiver repo.
The scenario is narrow: a provider sends order.created events, one of their deploys starts emitting total as a string instead of a number, and your handler writes that string into a numeric column three layers deep. A schema check at the edge turns that into a single rejected delivery with a precise error path instead of a corrupted ledger and a week of reconciliation. Which failure response you return — a 400 that the provider must fix, or a 202 plus a quarantined row — is a deliberate decision covered in Step 4, and it depends on the version policy described in webhook payload versioning best practices.
Prerequisites
- Node.js 20+ and TypeScript 5+, with
ajv@8(Draft 2020-12 build) andajv-formats@3installed. - An HTTP framework that can hand you the raw request body as a
Buffer— Express withexpress.raw(), or Fastify with a raw-body plugin. - Working signature verification, because validation must run on an authenticated body; see HMAC signature verification.
- A PostgreSQL table (or equivalent durable store) available for quarantined deliveries, and a dead-letter queue for anything that never becomes processable.
- A documented event catalogue: each event
type, its currentversion, and the fields your handler actually reads.
Step 1: Author the envelope and event data schemas
Split the contract in two. The envelope is the small, stable set of fields every event carries — identity, type, version, timestamp — and it is identical across every event type you accept. The data schema is the per-type body, and it changes on its own cadence. Validating the envelope first means you always know which data schema to reach for, even when the body is garbage.
Make both schemas closed with additionalProperties: false at the envelope level and pin the contract with a const on version. A closed envelope catches a provider that silently starts nesting the payload under a new key; leaving the data object open is usually the right call, because additive optional fields are the one change a well-behaved provider is allowed to ship without a version bump.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.internal/webhooks/envelope-2.json",
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "version", "created_at", "data"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"type": { "type": "string", "pattern": "^[a-z][a-z_]*\\.[a-z][a-z_]*$" },
"version": { "const": 2 },
"created_at": { "type": "string", "format": "date-time" },
"data": { "type": "object" }
}
}
The data schema for one event type lives beside it, keyed by the same version integer:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.internal/webhooks/order.created-2.json",
"type": "object",
"required": ["order_id", "currency", "total_minor", "line_items"],
"properties": {
"order_id": { "type": "string", "minLength": 1, "maxLength": 64 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"total_minor": { "type": "integer", "minimum": 0 },
"line_items": {
"type": "array",
"minItems": 1,
"maxItems": 500,
"items": {
"type": "object",
"required": ["sku", "quantity"],
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 }
}
}
}
}
}
Note total_minor as an integer of minor units rather than a float, and maxItems on the array. Bounds are not pedantry: an unbounded array is a memory-exhaustion vector on a public endpoint, and it is far cheaper to reject at the schema than to discover it under load.
Step 2: Compile every schema once with Ajv
Ajv compiles a schema into a generated JavaScript function. That compilation is expensive — hundreds of microseconds to low milliseconds — and calling ajv.compile() inside a request handler is one of the most common reasons a webhook endpoint that looked fine in staging blows its latency budget in production. Compile at process start, keep the functions in a Map, and let the request path do nothing but call them.
// validators.ts
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020";
import addFormats from "ajv-formats";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
const SCHEMA_DIR = path.join(__dirname, "schemas");
const FILE_RE = /^(.+)-(\d+)\.json$/;
// strict:true makes Ajv reject schemas with typos in keywords instead of
// silently ignoring them — a misspelled "requred" would otherwise validate nothing.
const ajv = new Ajv2020({ strict: true, allErrors: true, coerceTypes: false });
addFormats(ajv);
const envelopes = new Map<number, ValidateFunction>();
const dataSchemas = new Map<string, ValidateFunction>();
export function loadValidators(): void {
for (const file of readdirSync(SCHEMA_DIR)) {
const m = FILE_RE.exec(file);
if (!m) continue;
const [, name, versionText] = m;
const version = Number(versionText);
const doc = JSON.parse(readFileSync(path.join(SCHEMA_DIR, file), "utf8"));
const compiled = ajv.compile(doc);
if (name === "envelope") {
envelopes.set(version, compiled);
} else {
dataSchemas.set(`${name}@${version}`, compiled);
}
}
if (envelopes.size === 0) {
throw new Error("no envelope schema found — refusing to start unvalidated");
}
}
export function envelopeValidator(version: number): ValidateFunction | undefined {
return envelopes.get(version);
}
export function dataValidator(type: string, version: number): ValidateFunction | undefined {
return dataSchemas.get(`${type}@${version}`);
}
Two details matter. coerceTypes must stay false: with coercion on, the string "1999" quietly becomes the number 1999 and the exact bug this page exists to catch walks straight through. And throwing on an empty schema directory is deliberate — a container that ships without its schemas should fail its readiness probe, not accept traffic with validation disabled.
For the last few hundred microseconds, Ajv can emit standalone validator modules at build time (ajv/dist/standalone), turning schema compilation into a compile-time artifact and removing Ajv from the runtime dependency graph entirely. That is worth doing once your event catalogue passes a few dozen types; below that, start-up compilation is not measurable.
Step 3: Validate before any business logic runs
The ordering at the boundary is fixed and each step depends on the one before it: authenticate the raw bytes, parse them, validate the envelope, dispatch on type and version to the data validator, and only then hand a typed object to the domain code. Parsing before authenticating hands an unauthenticated attacker your JSON parser; validating after the first database write means the write already happened.
// receiver.ts
import express from "express";
import { dataValidator, envelopeValidator, loadValidators } from "./validators";
import { verifySignature } from "./signature";
import { problem, quarantine } from "./failure";
loadValidators();
const app = express();
app.use("/webhooks", express.raw({ type: "application/json", limit: "1mb" }));
interface Envelope {
id: string;
type: string;
version: number;
created_at: string;
data: Record<string, unknown>;
}
app.post("/webhooks", async (req, res) => {
const raw = req.body as Buffer;
if (!verifySignature(req.headers, raw)) {
return res.status(401).json(problem("invalid-signature", "signature did not verify", []));
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString("utf8"));
} catch {
return res.status(400).json(problem("malformed-json", "body is not valid JSON", []));
}
const version = Number((parsed as { version?: unknown })?.version);
const checkEnvelope = envelopeValidator(version);
if (!checkEnvelope) {
// An unknown envelope version is a contract change we have not shipped for yet.
await quarantine(raw, `unknown envelope version ${version}`);
return res.status(202).json({ status: "quarantined" });
}
if (!checkEnvelope(parsed)) {
return res.status(400).json(problem("envelope-invalid", "envelope failed validation",
(checkEnvelope.errors ?? []).map((e) => ({ path: e.instancePath || "/", detail: e.message ?? "" }))));
}
const envelope = parsed as Envelope;
const checkData = dataValidator(envelope.type, envelope.version);
if (!checkData) {
await quarantine(raw, `no schema for ${envelope.type}@${envelope.version}`);
return res.status(202).json({ status: "quarantined" });
}
if (!checkData(envelope.data)) {
return res.status(400).json(problem("data-invalid", `${envelope.type} failed validation`,
(checkData.errors ?? []).map((e) => ({ path: e.instancePath || "/", detail: e.message ?? "" }))));
}
await handleOrderCreated(envelope);
return res.status(202).json({ status: "accepted", id: envelope.id });
});
async function handleOrderCreated(envelope: Envelope): Promise<void> {
// Everything below this line can assume the payload matches the contract.
console.log("processing", envelope.id, envelope.type);
}
app.listen(8080);
Step 4: Return a useful 400 or quarantine the delivery
A rejection is a message to another engineering team, and 400 Bad Request with an empty body is a message that says nothing. Return the JSON Pointer path and the failing keyword for every error Ajv reported — that is why allErrors: true is set — in an RFC 9457 application/problem+json document, so the provider’s on-call can read the fault out of their own delivery log without asking you for the payload back.
But a 400 is only correct when the provider will actually see it. Many platforms treat any 4xx as permanent, mark the delivery dead and never retry — so rejecting a payload that you could later accept destroys the event. The decision is therefore not “is the payload valid” but “does the sender’s failure policy make a rejection safe”.
// failure.ts
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// CREATE TABLE webhook_quarantine (
// id BIGSERIAL PRIMARY KEY,
// received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
// reason TEXT NOT NULL,
// raw_body BYTEA NOT NULL,
// replayed_at TIMESTAMPTZ
// );
export interface ProblemDetail {
path: string;
detail: string;
}
export function problem(code: string, title: string, errors: ProblemDetail[]) {
return {
type: `https://errors.internal/webhooks/${code}`,
title,
status: 400,
errors, // e.g. [{ path: "/total_minor", detail: "must be integer" }]
};
}
export async function quarantine(raw: Buffer, reason: string): Promise<void> {
// Store the bytes exactly as received: the signature only verifies over the raw body,
// so a re-serialized copy can never be replayed through the same handler.
await pool.query(
"INSERT INTO webhook_quarantine (reason, raw_body) VALUES ($1, $2)",
[reason, raw],
);
}
Quarantine only pays off if someone drains it. Alert on webhook_quarantine row count, treat a non-empty table as an open incident, and replay rows through the same handler once the missing schema ships.
Step 5: Keep schemas versioned alongside the payload version
The file name carries the version, and a published file is immutable. order.created-2.json describes what version 2 means forever; a change to the contract means writing order.created-3.json and adding 3 to the accepted envelope versions. This is the receiver-side half of the policy in webhook payload versioning best practices — the sender declares the version, and your directory listing decides which declarations you can honour.
src/
schemas/
envelope-2.json
envelope-3.json
order.created-2.json
order.created-3.json
order.refunded-2.json
validators.ts
receiver.ts
Guard the mapping in CI so nobody bumps an envelope version without shipping the data schemas that go with it:
// schemas.contract.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync } from "node:fs";
import path from "node:path";
const files = readdirSync(path.join(__dirname, "schemas"));
const versions = new Set(
files.filter((f) => f.startsWith("envelope-")).map((f) => f.slice(9, -5)),
);
const SUPPORTED_TYPES = ["order.created", "order.refunded"];
test("every envelope version has a data schema for every supported type", () => {
for (const version of versions) {
for (const type of SUPPORTED_TYPES) {
assert.ok(
files.includes(`${type}-${version}.json`),
`missing ${type}-${version}.json for envelope version ${version}`,
);
}
}
});
Verification and testing
Two tests carry most of the weight: one proving a valid payload passes, and one proving the exact drift you fear is caught with a readable error path.
// validators.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { dataValidator, loadValidators } from "./validators";
loadValidators();
const valid = {
order_id: "ord_91f2",
currency: "GBP",
total_minor: 1999,
line_items: [{ sku: "SKU-1", quantity: 2 }],
};
test("a conforming order.created payload validates", () => {
const check = dataValidator("order.created", 2)!;
assert.equal(check(valid), true);
});
test("a stringified total is rejected with a usable path", () => {
const check = dataValidator("order.created", 2)!;
const drifted = { ...valid, total_minor: "1999" };
assert.equal(check(drifted), false);
const err = (check.errors ?? [])[0];
assert.equal(err.instancePath, "/total_minor");
assert.equal(err.keyword, "type");
});
Against a running receiver, the same drift should come back as a problem document rather than a stack trace:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/webhooks \
-H 'content-type: application/json' \
-H "x-signature: $(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)" \
--data "$BODY"
# 400 when BODY carries "total_minor": "1999"; 202 when it carries 1999
Assert on the log line too: a rejected delivery should emit one structured record carrying the event id, the schema key, and the first failing path — that record is what turns a provider’s “your endpoint is broken” ticket into a two-minute answer.
Failure modes and gotchas
- Compiling the schema inside the request handler.
ajv.compile()on every request adds milliseconds and allocates a new generated function each time, so a traffic spike turns into a garbage-collection spike. Compile inloadValidators()at start-up and treat a compile error as a fatal boot failure, not a per-request 500. coerceTypes: trueleft on from a tutorial. Type coercion is the single fastest way to make schema validation useless:"1999"becomes1999,"true"becomestrue, and the drift you deployed the schema to catch validates cleanly. Keep coercion off at the boundary and do explicit conversion in the domain layer if you need it.- Validating the parsed object but signing over the raw bytes. If you re-serialize the payload before storing or replaying it, key order and number formatting change and the signature no longer verifies. Keep the original
Bufferfor signature checks, quarantine rows and replays; use the parsed object only for validation and business logic. - Rejecting an unknown event type with a 400. A provider adding a new event type is normal, and a
4xxteaches their platform to disable your endpoint. Ignore types you do not subscribe to with a202, and reserve rejection for payloads that claim a type you do handle and then fail its schema.
Frequently Asked Questions
Are format uuid and format date-time actually enforced?
Only because ajv-formats is registered. Ajv treats format as an annotation by default, so the same schema without the addFormats call accepts any string at all in the id field and the constraint silently does nothing. Strict mode is the safety net here, since it surfaces an unrecognised format at compile time rather than at runtime. Keep a contract test that feeds a malformed uuid through the validator, because this is the one failure that produces no log line to notice.
What does allErrors true cost, and can the error list get out of hand?
Ajv normally stops at the first failure; allErrors keeps going, which is what lets a provider fix every path in one round trip instead of one per redeploy. The cost is negligible for the envelope and unbounded for arrays: a 500-item line_items array with a wrong type in each item produces 500 error objects, and serialising them all makes the rejection body larger than the request that caused it. Truncate to the first 20 entries in the problem document and report the total count alongside them.
What happens to a payload that exceeds the 1mb raw body limit?
Express rejects it before the handler runs, so neither the signature check nor the schema check ever executes and the provider receives a bare 413. Platforms that treat 413 as permanent will drop that event outright. Size the transport limit from the contract itself — maxItems multiplied by a realistic per-item ceiling, with headroom — and alert on 413 counts, because a nonzero rate means the schema bounds and the body limit have drifted out of agreement.
Should the data object be closed with additionalProperties false as well?
Only when rejecting unknown keys there buys you something concrete. A provider adding an optional attribute inside data is a routine release on their side, and a closed data schema converts it into an outage on yours. The case for closing it is when the handler persists the whole data object rather than reading named fields out of it, because an unknown key then lands in your storage without anyone having reviewed it.
When is it safe to delete an old schema file?
Not when the sender stops emitting that version, but after their full retry horizon has elapsed on top of it, since a delivery first attempted days ago can still land. Removing the file early makes the data validator lookup return undefined, which pushes live events into quarantine and reads like a provider fault when it is entirely self-inflicted. Keep a counter keyed by type and version, and retire the file only once that counter has been flat at zero for longer than the longest retry window the sender documents.
Should validation run at the boundary or inside the async worker?
At the boundary. A precompiled validator costs microseconds, so it does not meaningfully delay the acknowledgement, and running it first keeps payloads that can never succeed out of the queue where a worker would retry them until they reach the dead-letter queue. Deferring makes sense only when a rule depends on state you cannot read on the request path, and even then the envelope check stays at the edge so you always know which contract the body claims to follow.
Related
- Schema registry for webhook events — where these schema documents are governed and compatibility-checked before they reach the receiver.
- Webhook payload versioning best practices — the version policy the
constkeyword in the envelope enforces. - Event schema design — envelope structure and contract design for webhook events.
- Webhook architecture fundamentals & design patterns — how the receiver boundary fits the wider delivery model.