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.

Schema registry publish and read flow A candidate schema passes a compatibility check before storage, then producers and consumers read the stored version. Candidate schema Compatibility check Registry (versioned) Producers Consumers reject if breaking
A candidate schema is admitted to the registry only after passing the compatibility check; producers and consumers read versioned schemas back out.

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.

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