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

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.

Anatomy of an event_schemas row A sample registry row showing event_type, version, schema_doc, and created_at, with callouts explaining the composite primary key, verbatim JSONB storage, and the audit trail. Anatomy of one registry row event_type version schema_doc created_at order.created 3 { type, required, ... } 2026-07-25T10:04Z PRIMARY KEY (event_type, version) one row per published revision JSONB kept verbatim served back to Ajv Audit trail for rollback decisions
Because the primary key spans both columns, a published version is immutable: a change is always a new row, never an update in place.

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.

Compatibility checker control flow The checker tests for removed properties, changed types, dropped enum values, and newly required properties; any match rejects the publish, otherwise the schema is accepted as the next version. Property removed from the new schema? Breaking: consumers lose a field they read Type changed on an existing property? Breaking: deserialization fails on old payloads Enum values dropped from a property? Breaking: values that were valid are now rejected New property added to required? Breaking: old payloads omit the new field Compatible publish as version N + 1 no no no no yes yes yes yes
Four rejection tests and one accept: the checker is deliberately conservative, so an ambiguous change fails the publish rather than reaching consumers.

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.

Schema registry publish and read flow A candidate schema passes a compatibility check before storage, then producers and consumers read the stored version back out by event type and version. Candidate schema Compatibility check Registry (versioned) Producers Consumers reject if breaking read by event type and version
A candidate schema is admitted to the registry only after passing the compatibility check; producers and consumers read versioned schemas back out by event type.
// 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.

Validator resolution sequence A consumer handler asks the in-process cache for a validator; on a miss the cache selects the schema document from PostgreSQL, compiles it, memoizes it, and returns the compiled validator. Consumer handler inbound webhook Validator cache in process Registry PostgreSQL getValidator(type, v) cache hit on type@v? SELECT schema_doc schema row compile and memoize compiled validator
Only a cache miss reaches PostgreSQL, so the database sees one query per deployed schema version rather than one per delivered 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

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.