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.
-
events.inventory.yaml— compiled, machine-owned, regenerated on every docs build. -
events.delivery.yaml— human-owned; every event key must exist here with signed fields. -
events.narrative.md— optional draft prose; every paragraph must cite an inventoryid.
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
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."]
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()
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
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"
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())
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
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.
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.
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]
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)