Building a central schema registry for webhook events
When dozens of event types flow through a webhook platform, the schema for each payload becomes a contract that producers and consumers must agree on. This guide builds a central registry that stores every webhook event payload as a JSON Schema, versions it, and refuses to publish a change that would break existing consumers. It builds on event schema design with a concrete TypeScript implementation, and pairs naturally with webhook payload versioning best practices, which covers the semantic-versioning policy the registry enforces.
A registry turns “the payload changed and three downstream teams broke” into a build-time failure. Every schema lives in one place, every change is checked against the prior version, and validators load the exact contract that matches the X-Event-Type and X-Event-Version headers on the wire.
Prerequisites
- Node.js 18+ and TypeScript 5+ with
ts-nodefor the examples. ajv(Draft 2020-12 support) andpgfor PostgreSQL access.- A PostgreSQL instance reachable via
DATABASE_URL. - A naming convention for event types (for example
order.created,invoice.paid) and amajor.minor.patchversion policy already agreed with your teams.
Step 1: Model the registry storage
Store each schema as a row keyed by (event_type, version). Keep the raw JSON Schema document intact so it can be served back byte-for-byte to validators.
CREATE TABLE event_schemas (
event_type TEXT NOT NULL,
version INTEGER NOT NULL, -- monotonically increasing per event_type
schema_doc JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (event_type, version)
);
A single integer version per event_type is enough for the registry’s internal bookkeeping; the human-facing major.minor.patch string can be carried inside schema_doc under a $comment or a dedicated x-semver field if you want both.
Step 2: Implement the compatibility checker
Backward compatibility for JSON Schema, in practice, means: a payload that was valid under the old schema must still be valid under the new one. The two changes that most often break consumers are adding a required property and narrowing a type or enum. The checker below catches those cases without trying to be a full schema-theory solver.
// compatibility.ts
type JSONSchema = {
type?: string;
required?: string[];
properties?: Record<string, JSONSchema>;
enum?: unknown[];
};
export type CompatResult =
| { compatible: true }
| { compatible: false; reasons: string[] };
export function checkBackwardCompatible(
oldSchema: JSONSchema,
newSchema: JSONSchema,
): CompatResult {
const reasons: string[] = [];
const oldRequired = new Set(oldSchema.required ?? []);
const newRequired = new Set(newSchema.required ?? []);
// Newly required properties break old payloads that omitted them.
for (const prop of newRequired) {
if (!oldRequired.has(prop)) {
reasons.push(`property "${prop}" became required`);
}
}
// Removing a property that consumers depend on is breaking.
const oldProps = oldSchema.properties ?? {};
const newProps = newSchema.properties ?? {};
for (const prop of Object.keys(oldProps)) {
if (!(prop in newProps)) {
reasons.push(`property "${prop}" was removed`);
continue;
}
// Type narrowing on an existing property is breaking.
const before = oldProps[prop].type;
const after = newProps[prop].type;
if (before && after && before !== after) {
reasons.push(`property "${prop}" changed type ${before} -> ${after}`);
}
// Shrinking an enum rejects values that used to be valid.
const beforeEnum = oldProps[prop].enum;
const afterEnum = newProps[prop].enum;
if (beforeEnum && afterEnum) {
const allowed = new Set(afterEnum);
const dropped = beforeEnum.filter((v) => !allowed.has(v));
if (dropped.length) {
reasons.push(`property "${prop}" enum dropped ${JSON.stringify(dropped)}`);
}
}
}
return reasons.length ? { compatible: false, reasons } : { compatible: true };
}
Step 3: Gate the publish endpoint
The publish path is where the contract is enforced. Fetch the latest registered version, run the check, and only insert when the change is safe — or when the caller has explicitly declared a breaking change by bumping the major version.
// registry.ts
import { Pool } from "pg";
import Ajv from "ajv/dist/2020";
import { checkBackwardCompatible } from "./compatibility";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ajv = new Ajv({ strict: true, allErrors: true });
export async function publishSchema(
eventType: string,
schemaDoc: object,
allowBreaking = false,
): Promise<{ version: number }> {
// Reject schemas that are not themselves valid JSON Schema.
if (!ajv.validateSchema(schemaDoc)) {
throw new Error(`invalid JSON Schema: ${ajv.errorsText(ajv.errors)}`);
}
const { rows } = await pool.query(
`SELECT version, schema_doc FROM event_schemas
WHERE event_type = $1 ORDER BY version DESC LIMIT 1`,
[eventType],
);
let nextVersion = 1;
if (rows.length) {
const latest = rows[0];
const result = checkBackwardCompatible(latest.schema_doc, schemaDoc as never);
if (!result.compatible && !allowBreaking) {
throw new Error(
`breaking change rejected: ${result.reasons.join("; ")}. ` +
`Pass allowBreaking to publish under a new major version.`,
);
}
nextVersion = latest.version + 1;
}
await pool.query(
`INSERT INTO event_schemas (event_type, version, schema_doc) VALUES ($1, $2, $3)`,
[eventType, nextVersion, schemaDoc],
);
return { version: nextVersion };
}
Step 4: Serve schemas to validators
Producers validate before they send; consumers validate on receipt. Both read from the registry, so cache compiled validators in process to avoid recompiling on every event.
// validator-cache.ts
import { Pool } from "pg";
import Ajv, { ValidateFunction } from "ajv/dist/2020";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ajv = new Ajv({ strict: true });
const cache = new Map<string, ValidateFunction>();
export async function getValidator(
eventType: string,
version: number,
): Promise<ValidateFunction> {
const key = `${eventType}@${version}`;
const cached = cache.get(key);
if (cached) return cached;
const { rows } = await pool.query(
`SELECT schema_doc FROM event_schemas WHERE event_type = $1 AND version = $2`,
[eventType, version],
);
if (!rows.length) throw new Error(`no schema for ${key}`);
const validate = ajv.compile(rows[0].schema_doc);
cache.set(key, validate);
return validate;
}
A consumer reads the X-Event-Type and X-Event-Version headers, fetches the matching validator, and rejects any payload that fails before it touches business logic.
Verification
Add a unit test that proves the checker rejects a newly required field and accepts an additive optional one.
// compatibility.test.ts
import assert from "node:assert";
import { checkBackwardCompatible } from "./compatibility";
const base = { type: "object", required: ["id"], properties: { id: { type: "string" } } };
// Adding an optional property is safe.
const additive = { ...base, properties: { ...base.properties, note: { type: "string" } } };
assert.deepEqual(checkBackwardCompatible(base, additive), { compatible: true });
// Making "note" required breaks old payloads.
const breaking = { ...additive, required: ["id", "note"] };
const result = checkBackwardCompatible(base, breaking);
assert.equal(result.compatible, false);
console.log("compatibility checks passed");
You can also exercise the publish gate end to end with curl against a thin HTTP wrapper:
curl -fsS -X POST localhost:3000/schemas/order.created \
-H 'content-type: application/json' \
--data '{"type":"object","required":["id","total"],"properties":{"id":{"type":"string"},"total":{"type":"number"}}}'
# A second POST that drops "total" or marks a new field required should return HTTP 409.
Failure modes and gotchas
- The checker only inspects the top level. Nested objects and
$refindirection can hide breaking changes the simple checker misses. Either flatten schemas before checking or extend the traversal recursively — and always treat a$refchange as breaking until proven otherwise. additionalProperties: falseis a trap. Flipping an open schema to closed retroactively rejects payloads that carried extra fields. Treat tighteningadditionalPropertiesas a breaking change even though no required field moved.- Stale validator cache after a publish. The in-process cache is keyed by version, so a new version is never confused with an old one — but a long-lived consumer that hard-codes a version will silently keep validating against the old contract. Drive the version from the inbound header, not from configuration.
- Forgetting the major-version escape hatch. Some changes genuinely must break. Without
allowBreakingplus a new major version, teams will route around the registry and publish out of band, which defeats the contract. Make the escape hatch explicit and audited rather than nonexistent.