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

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.

Webhook envelope anatomy The envelope's id, type, version and data fields are broken out, each with a note describing what the schema check enforces for that field. Signed envelope POST /webhooks · application/json id: string, format uuid one id per event, reused on every retry type: order.created selects which data schema is applied version: const 2 pins the contract; a bump is a new file data: object left open for additive optional fields envelope is closed: additionalProperties false
Each envelope field earns its keyword: the id anchors deduplication, the type routes to a data schema, and the version constant is what makes a contract change impossible to ship silently.
{
  "$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.

Validation order at the receiver boundary A provider posts a signed payload; the ingress handler verifies the signature, validates the envelope and the data schema, and only then calls the order service before answering. Provider Ingress handler Order service POST raw signed body verify signature validate envelope validate data schema typed OrderCreated 202 Accepted any check that fails short-circuits here — the order service is never called
Validation is a gate, not a decoration: the order service only ever sees payloads that already satisfied the signature, the envelope and the data schema.
// 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”.

Reject or quarantine decision After a schema failure, the branch turns on whether the provider drops events on a 4xx: if it does, accept and quarantine; if not, reject with a problem document. schema check failed does a 4xx kill the event? no, it retries yes, it drops 400 Bad Request problem+json body 202 Accepted raw body quarantined sender fixes and resends you replay after the fix
The branch turns on the sender's failure policy, not on your preference: quarantine buys you a replay when a rejection would delete the only copy of the event.
// 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

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.