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.
Four columns carry the whole contract. The composite primary key is what makes a version immutable once published, and created_at is what lets you answer “which schema was live when this delivery failed” during an incident.
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 };
}
Read as a flowchart, the function is four independent rejection tests followed by a single accept. Any one of them firing is enough to reject the publish; only a candidate that clears all four earns the next version number.
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. The rule authoring on the other side of that validator — what to make required, how strict to be about additionalProperties, how to report errors — is covered in validating webhook payloads with JSON Schema.
The read path matters more than the publish path for steady-state latency: it runs on every single delivery. The cache turns all but the first lookup per version into a map hit, so a busy consumer touches PostgreSQL once per deployed schema version rather than once per event.
Verification and testing
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.
Frequently Asked Questions
What should a consumer do when a delivery names a version the registry has never seen?
The lookup returns no rows and getValidator throws, so the handler has to classify the miss. A version above the newest registered one usually means a producer deployed ahead of the publish, which is a 503 that the provider's retry will resolve once the schema lands. A version below the oldest one means the row was pruned, which is a 400 worth alerting on. Never silently fall back to the latest version, because that validates the payload against a contract it was not written for.
Does the registry have to be reachable on every delivery?
Only for the first delivery of each event type and version pair. After that the compiled validator is memoized in process, so a registry outage is invisible to traffic the pod has already seen and blocks only newly published versions. The exposure is restarts during the outage, since a fresh pod starts with an empty map; a read replica or a periodic on-disk snapshot of the schema rows covers that case.
Is adding additionalProperties false to an existing schema a safe change?
No, and the checker in this guide will wave it through because no property was removed, retyped, or made required. Producers that have been sending an undocumented extra field start failing validation the instant the schema closes, and you find out from consumer error rates rather than from the publish gate. Add an explicit test for the transition from open to closed, or ship the closed schema under a new major version so producers migrate deliberately.
Does the validator cache need an eviction policy?
Usually not. Cardinality is bounded by the event type and version combinations actually on the wire, which is tens of entries, and old versions stop appearing once producers migrate. An unbounded Map is fine at that scale. It stops being fine if you mint schema versions per tenant, because cardinality then tracks customer count rather than contract count, and an LRU with a hard cap becomes necessary.
Can a published version be corrected in place when the schema has a mistake?
The composite primary key turns that into an UPDATE, which changes the contract underneath every consumer that already fetched and cached that version. Publish the fix as the next version and let the compatibility gate confirm it is additive. If the bad version demonstrably never reached a consumer, deleting the row and republishing is defensible, but treat it with the same audit trail you would apply to a production rollback.
Will the checker catch a breaking change buried inside a nested object?
Not as written. It compares top-level properties and required only, so narrowing a field two levels down publishes cleanly. Recursing into properties when both sides describe objects closes most of the gap, and any change to a ref target should be classified as breaking outright, since deciding otherwise means resolving and walking the whole schema graph. Until that work is done, treat the gate as a filter for the common mistakes rather than a proof of compatibility.
Related
- Webhook payload versioning best practices — the semantic bump policy the registry enforces.
- Validating webhook payloads with JSON Schema — writing the rules the registry stores.
- Event schema design — envelope structure and ingress validation.
- Webhook architecture fundamentals & design patterns — end-to-end delivery context.