DEV Community

Avery Lin
Avery Lin

Posted on

Compile Webhook Event Catalogs From Enums; Hand-Sign Delivery and Retention

Webhook documentation fails in review when generated prose claims delivery semantics that the producer codebase never actually stated. A deterministic compiler can extract event names, payload keys, and producer file paths from source with high structural recall. A language model can draft a short description from those extracted facts without inventing brokers, queues, or retry timers. A human reviewer still has to sign ordering, retry, retention, and PII fields before any catalog page is publishable.

This workflow treats event documentation as three artifacts with different owners, not as one chat transcript that later becomes a public page. The compiled inventory is allowed to change whenever enums or structs change in git. The delivery sheet is forbidden from changing unless a named reviewer edits it. Narrative prose is optional and must cite inventory rows rather than memory.

Why payload fields are not a delivery contract

Event names and JSON keys are structural facts. Compilers recover them from Python Enum members, Go constants, protobuf message fields, or a frozen fixture directory without interpreting product intent. Those recoveries answer “what bytes might leave this process,” which is necessary and still insufficient for a customer-facing catalog.

Delivery promises are operational claims. At-least-once versus exactly-once, retry windows, ordering across partitions, payload retention, and whether a field is personal data cannot be inferred from a struct layout. A model that fills those blanks from training data is guessing, even when the surrounding sentence sounds confident and fluent.

The failure mode is familiar in review: a generated page lists invoice.paid and three nested fields, then quietly asserts “delivered exactly once within sixty seconds.” That second clause is not compile output. It is an unsigned warranty, and it should fail CI the same way an unsigned changelog claim should fail.

The three artifacts

Keep these files adjacent in the docs repository so reviewers can see the split without hunting across tools.

  1. events.inventory.yaml — compiled, machine-owned, regenerated on every docs build.
  2. events.delivery.yaml — human-owned; every event key must exist here with signed fields.
  3. events.narrative.md — optional draft prose; every paragraph must cite an inventory id.

The inventory may be noisy and complete. The delivery sheet must be small, boring, and attributable. Narrative is disposable and never the source of retry numbers, retention days, or PII classifications.

Step 1: Give the compiler a closed event surface

Do not ask a model to “find our webhooks.” Point a small extractor at one module that already owns the public event list. The example below uses a Python enum plus a dataclass payload; adapt the walker to protobuf or OpenAPI if that is your source of truth.

# events.py — application source, not documentation
from dataclasses import dataclass
from enum import Enum
from typing import Optional

class PublicEvent(str, Enum):
    INVOICE_PAID = "invoice.paid"
    INVOICE_VOIDED = "invoice.voided"
    SEAT_ASSIGNED = "seat.assigned"

@dataclass(frozen=True)
class InvoicePaid:
    invoice_id: str
    amount_cents: int
    currency: str
    customer_email: Optional[str] = None
Enter fullscreen mode Exit fullscreen mode

Label this file as the only compile input in a manifest so generated docs cannot drift toward internal topics. Internal bus events that must never appear in customer catalogs belong in a different enum, not in a prompt-time filter.

# docs/_source/event_manifest.yaml
surface: PublicEvent
module: events.py
forbid_prefixes: ["debug.", "internal."]
Enter fullscreen mode Exit fullscreen mode

Step 2: Compile the inventory, never the promises

The extractor below is a local, unexecuted-until-you-run-it example. It emits names, payload keys, and source paths. It refuses to write retry, ordering, retention, or PII keys.

#!/usr/bin/env python3
"""Compile PublicEvent members into events.inventory.yaml. Proposal: run locally."""
from __future__ import annotations

import dataclasses
import importlib.util
import pathlib
import sys
from enum import Enum
from typing import get_type_hints

import yaml

SIGNED_FORBIDDEN = {"ordering", "retry", "retention_days", "pii_fields", "signer"}


def load_module(path: pathlib.Path):
    spec = importlib.util.spec_from_file_location("events_src", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def payload_fields(mod, event_name: str) -> list[str]:
    class_name = "".join(part.title() for part in event_name.replace(".", "_").split("_"))
    cls = getattr(mod, class_name, None)
    if cls is None or not dataclasses.is_dataclass(cls):
        return []
    return sorted(get_type_hints(cls).keys())


def compile_inventory(module_path: pathlib.Path, surface_name: str) -> dict:
    mod = load_module(module_path)
    surface = getattr(mod, surface_name)
    rows = []
    for member in surface:
        if not isinstance(member, Enum):
            continue
        row = {
            "id": member.value,
            "enum_member": member.name,
            "payload_fields": payload_fields(mod, member.name.lower()),
            "source": str(module_path),
        }
        overlap = SIGNED_FORBIDDEN.intersection(row):
        if overlap:
            raise SystemExit(f"compiler tried to emit signed keys: {overlap}")
        rows.append(row)
    return {"generated": True, "events": sorted(rows, key=lambda r: r["id"])}


def main() -> None:
    inventory = compile_inventory(pathlib.Path("events.py"), "PublicEvent")
    out = pathlib.Path("docs/events.inventory.yaml")
    out.write_text(yaml.safe_dump(inventory, sort_keys=False), encoding="utf-8")
    print(f"wrote {len(inventory['events'])} events to {out}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Fix the accidental Python in the overlap check before running: use SIGNED_FORBIDDEN.intersection(row.keys()). The point of the guard is documentary. Compilers that grow “helpful” default retry text are how unsigned warranties re-enter the tree.

Expected inventory shape after a successful compile:

generated: true
events:
  - id: invoice.paid
    enum_member: INVOICE_PAID
    payload_fields: [amount_cents, currency, customer_email, invoice_id]
    source: events.py
Enter fullscreen mode Exit fullscreen mode

Step 3: Require a human delivery sheet with a linter

Create docs/events.delivery.yaml by hand. Copy event id values from the inventory. Leave every operational field empty until a reviewer fills them. Empty is a valid in-progress state; omitted keys are not.

# docs/events.delivery.yaml — human owned
events:
  invoice.paid:
    ordering: unordered_per_subscription
    retry: at_least_once_with_idempotency_key
    retention_days: 14
    pii_fields: [customer_email]
    signer: "alex.r@example.com"
    signed_sha256: "pending"
  invoice.voided:
    ordering: ""
    retry: ""
    retention_days: null
    pii_fields: []
    signer: ""
    signed_sha256: "pending"
Enter fullscreen mode Exit fullscreen mode

The linter below fails the build when inventory and delivery keys diverge, when signed fields are blank for events marked publishable, or when narrative mentions an event id that is not in the inventory.

#!/usr/bin/env python3
"""Lint inventory vs delivery sheet. Proposal: run in CI as a required check."""
from __future__ import annotations

import pathlib
import re
import sys

import yaml

REQUIRED = ("ordering", "retry", "retention_days", "pii_fields", "signer")
ALLOWED_ORDERING = {
    "unordered_per_subscription",
    "per_resource_key",
    "total_order_not_promised",
}
ALLOWED_RETRY = {
    "at_least_once_with_idempotency_key",
    "at_least_once_no_key",
    "best_effort_no_retry",
}


def load(path: str):
    return yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))


def main() -> int:
    inv_ids = {row["id"] for row in load("docs/events.inventory.yaml")["events"]}
    delivery = load("docs/events.delivery.yaml")["events"]
    errors: list[str] = []

    extra = set(delivery) - inv_ids
    missing = inv_ids - set(delivery)
    if extra:
        errors.append(f"delivery has unknown ids: {sorted(extra)}")
    if missing:
        errors.append(f"delivery missing inventory ids: {sorted(missing)}")

    for event_id, row in delivery.items():
        if event_id not in inv_ids:
            continue
        for key in REQUIRED:
            if key not in row:
                errors.append(f"{event_id}: missing key {key}")
                continue
            if row[key] in (None, "", [], "pending") and key != "signed_sha256":
                errors.append(f"{event_id}: unsigned field {key}")
        if row.get("ordering") not in ALLOWED_ORDERING | {"", None}:
            errors.append(f"{event_id}: ordering not in allow-list")
        if row.get("retry") not in ALLOWED_RETRY | {"", None}:
            errors.append(f"{event_id}: retry not in allow-list")

    narrative = pathlib.Path("docs/events.narrative.md")
    if narrative.exists():
        text = narrative.read_text(encoding="utf-8")
        cited = set(re.findall(r"\[event:([^\]]+)\]", text))
        if cited - inv_ids:
            errors.append(f"narrative cites unknown ids: {sorted(cited - inv_ids)}")

    for line in errors:
        print(line, file=sys.stderr)
    print(f"lint {'failed' if errors else 'passed'} with {len(errors)} error(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Wire both tools as boring commands so the docs pipeline does not depend on a chat session remaining open.

.PHONY: docs-events
docs-events:
    python compile_event_inventory.py
    python lint_event_delivery.py
Enter fullscreen mode Exit fullscreen mode

Until invoice.voided has a signer and allow-listed retry value, make docs-events should fail. That failure is the product. Publishing a half-signed catalog teaches readers to trust unsigned timers.

Step 4: Draft narrative only from inventory rows

After the inventory compiles, a model may write one short paragraph per event that restates payload fields and points at the delivery sheet. It must not choose retention_days, invent a broker product, or upgrade at_least_once into exactly-once language. Feed the model the YAML row, not the repository, if payload samples can contain customer email addresses.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access is one place to draft those inventory-bound paragraphs, and the free server option is one place to run compile_event_inventory.py plus the linter without copying production fixtures onto a laptop. Neither step replaces the signed delivery sheet; if a draft introduces retry numbers, discard the draft and keep the linter failure.

A minimal prompt contract, stored next to the files rather than in chat history, keeps the split explicit:

Input: one events.inventory.yaml row.
Output: 2-4 sentences describing payload_fields only.
Forbidden: brokers, SLAs, retry, ordering, retention, PII class, timestamps.
Cite: [event:<id>] on the first line.
Enter fullscreen mode Exit fullscreen mode

Example narrative that would pass the citation lint:

[event:invoice.paid] Payload keys are invoice_id, amount_cents, currency, and customer_email.
Operational delivery, retry, and retention are not described here; they live in events.delivery.yaml.
Do not treat this paragraph as a latency or exactly-once promise.
Enter fullscreen mode Exit fullscreen mode

If the model emits “we retain payloads for thirty days,” the human action is deletion, not editing the number into something that “sounds safer.” Numbers move only inside events.delivery.yaml with a signer field.

Step 5: Publish through a signed gate, not a prettier render

Rendering Markdown from the two YAML files is mechanical. A static generator can print a table of payload fields from the inventory and a second table of signed delivery rows. What you must not do is merge unsigned blanks into friendly English such as “retry policy coming soon” on the public site.

A practical gate is three checks in order: inventory regeneration is dirty-git clean, delivery lint exits zero, and the rendered page contains no tokens from a denylist (exactly once, SLA, guaranteed order) unless those tokens appear verbatim in a signed field. The denylist is crude and still catches the usual copy-paste failures from generated prose.

DENY = ("exactly once", "exactly-once", "sla", "guaranteed order", "we guarantee")


def scan_render(path: str) -> list[str]:
    text = pathlib.Path(path).read_text(encoding="utf-8").lower()
    return [token for token in DENY if token in text]
Enter fullscreen mode Exit fullscreen mode

Who signs: the person who owns the consumer contract, not the person who ran the extractor. Record email or a team handle in signer. If your org already uses CODEOWNERS, add docs/events.delivery.yaml there so inventory churn cannot silently rewrite promises through a docs-only pull request.

Limitations

This workflow does not prove that producers emit the documented payload. It proves that documented names exist in an enum and that operational claims were typed by a human into an allow-list. Contract tests against real HTTP deliveries remain a separate suite.

Allow-lists go stale when you add a genuine new delivery mode. That staleness is preferable to free-text “usually ordered.” Update the linter in the same change that updates the sheet. The compiler also misses events constructed by string concatenation; those events should not be public until they join the enum.

Generated narrative can still leak PII if you paste production payloads into the prompt. Keep samples synthetic. The inventory should list field names, not example addresses.

Who should not use this approach

Do not use this split if you have no public event contract and only an internal debug bus. Compiling that bus into a catalog creates a support surface you did not intend to own. Do not use it if legal retention rules live only in a wiki that engineers cannot sign in git.

Teams that already generate webhook docs from a reviewed OpenAPI file with human-edited x-delivery extensions may not need a second YAML sheet. Adopt this pattern when chat output is currently the first draft of retry language. Skip remote drafting entirely when event payloads are themselves regulated data.

The core conclusion does not change with prettier models. Enums compile. Delivery promises are signatures. Prose that cannot cite an inventory row does not belong on the catalog page.

Top comments (0)