DEV Community

Avery Lin
Avery Lin

Posted on

Register Webhook Events From Handlers, Then Sign Delivery Semantics Before Publish

Generated webhook pages fail when a model invents delivery guarantees the handlers never encoded. Compile an event catalog from registrations, then restrict drafting to that catalog. Require a human signature for retry, ordering, authentication, and payload-retention claims before any publish. Receivers plan queues and signature checks from those pages, so unsigned promises become incidents the producer never staffed.

Why webhook documentation drifts from handlers

Public webhook documentation is a push contract, not a request-response catalog copied from controllers. Receivers size queues, choose idempotency keys, and write signature checks from the published event list. When generated prose adds exactly-once delivery or global ordering, operators inherit on-call work the producer never accepted.

Handler code usually names events and JSON fields with little ambiguity across versions. Delivery semantics live in retry workers, broker settings, and legal reviews that a drafting pass cannot see. Treating those two layers as one generation job produces confident pages that fail at the first timeout. A durable split keeps the catalog mechanical and the promises owned by a named reviewer.

The model may draft examples and field narratives inside extracted shapes only. A reviewer must sign every claim about retries, ordering, signatures, PII, and retention. Unsigned rows should block the docs release, even when the catalog extract is complete and the examples render cleanly.

Decision table: draftable text versus owned promises

Claim class Source of truth Model may do Human must own
Event name and version Handler registry Extract and normalize Reject unknown names
Payload fields and types Serializer or schema Draft JSON examples Confirm required versus optional
Signature header format Spec plus test vectors Format markdown Confirm algorithm and canonical string
Retry schedule Worker or broker config Leave blank Sign intervals and max attempts
Delivery semantics Architecture review Leave blank Sign at-least-once or a weaker term
Ordering Broker topology Leave blank Sign per-key ordering or none
PII in payload Legal review Flag candidate fields Sign redaction and retention
Subscription authentication Gateway config Draft setup outline Sign credential lifetime
Breaking-change window Product policy Leave blank Sign sunset dates

Treat the right-hand column as a publish gate rather than a style preference. If a value is missing, the page stays draft.

1. Keep a registry the extractor can read

Do not scrape prose comments as the event source of truth. Register each outbound event beside the emitter with a stable name, a version, and a schema identifier the compiler can hash.

# webhook_registry.py — proposed example, not production telemetry
from dataclasses import dataclass, field
from typing import Callable

REGISTRY = {}

@dataclass(frozen=True)
class FieldSpec:
    name: str
    json_type: str
    required: bool
    pii_candidate: bool = False

@dataclass
class EventSpec:
    name: str
    version: str
    schema_id: str
    fields: tuple[FieldSpec, ...]
    emitter: Callable | None = field(default=None, compare=False)

def webhook_event(name: str, version: str, schema_id: str, fields: list[FieldSpec]):
    def wrap(fn: Callable) -> Callable:
        key = f"{name}@{version}"
        if key in REGISTRY:
            raise ValueError(f"duplicate webhook registration: {key}")
        REGISTRY[key] = EventSpec(name, version, schema_id, tuple(fields), fn)
        return fn
    return wrap

@webhook_event(
    name="invoice.finalized",
    version="2026-09-21",
    schema_id="invoice.finalized.v1",
    fields=[
        FieldSpec("event_id", "string", True),
        FieldSpec("invoice_id", "string", True),
        FieldSpec("currency", "string", True),
        FieldSpec("total_cents", "integer", True),
        FieldSpec("customer_email", "string", False, pii_candidate=True),
    ],
)
def emit_invoice_finalized(payload: dict) -> None:
    raise NotImplementedError("transport is out of scope for the catalog compiler")
Enter fullscreen mode Exit fullscreen mode

The decorator fails closed on duplicate keys so two handlers cannot silently share a public name. Field metadata stays structural: types, required flags, and PII candidates. It does not encode retry policy, ordering, or retention, which belong in a signed contract file reviewed by people.

2. Compile a handler catalog in CI

Run a small compiler on every merge so the public event list cannot drift from registered handlers. Write JSON that lists events, fields, and schema identifiers without narrative promises about delivery.

# compile_webhook_catalog.py
import hashlib, json, sys
from webhook_registry import REGISTRY

def catalog_entry(spec) -> dict:
    field_blob = json.dumps(
        [(f.name, f.json_type, f.required, f.pii_candidate) for f in spec.fields],
        separators=(",", ":"),
    )
    return {
        "name": spec.name,
        "version": spec.version,
        "schema_id": spec.schema_id,
        "schema_sha256": hashlib.sha256(field_blob.encode()).hexdigest(),
        "fields": [
            {
                "name": f.name,
                "json_type": f.json_type,
                "required": f.required,
                "pii_candidate": f.pii_candidate,
            }
            for f in spec.fields
        ],
    }

def main() -> int:
    events = sorted((catalog_entry(s) for s in REGISTRY.values()), key=lambda e: (e["name"], e["version"]))
    json.dump({"events": events}, sys.stdout, indent=2)
    sys.stdout.write("\n")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode
python compile_webhook_catalog.py > artifacts/webhook_catalog.json
Enter fullscreen mode Exit fullscreen mode

Keep this file in CI artifacts or a reviewed docs branch. It is evidence of what shipped, not a blog paragraph and not a place to store secrets, signing keys, or customer payloads.

3. Lint markdown against the catalog and a forbidden-claim list

Before any model drafts receiver tutorials, fail the build when markdown names events absent from the catalog. Also fail when a registered event has no documentation stub, and when unsigned guarantee phrases appear in the draft.

# lint_webhook_docs.py — proposed CI check
import json, pathlib, re, sys

FORBIDDEN = [
    re.compile(r"\bexactly[- ]once\b", re.I),
    re.compile(r"\bguaranteed order(?:ing)?\b", re.I),
    re.compile(r"\bwe retain\b", re.I),
    re.compile(r"\bretry(?:s|es)? \d+", re.I),
    re.compile(r"\bwill never (?:drop|reorder)\b", re.I),
]

EVENT_MENTION = re.compile(r"`([a-z0-9_.]+@[0-9-]+)`")

def load_catalog(path: pathlib.Path) -> set[str]:
    data = json.loads(path.read_text())
    return {f"{e['name']}@{e['version']}" for e in data["events"]}

def main(catalog_path: str, docs_dir: str) -> int:
    known = load_catalog(pathlib.Path(catalog_path))
    mentioned = set()
    errors = []
    for md in pathlib.Path(docs_dir).rglob("*.md"):
        text = md.read_text()
        for pat in FORBIDDEN:
            if pat.search(text):
                errors.append(f"{md}: unsigned delivery phrase matched {pat.pattern}")
        mentioned.update(EVENT_MENTION.findall(text))
    extra = sorted(mentioned - known)
    missing = sorted(known - mentioned)
    for name in extra:
        errors.append(f"docs mention unregistered event {name}")
    for name in missing:
        errors.append(f"registered event {name} has no markdown mention")
    for line in errors:
        print(line, file=sys.stderr)
    return 1 if errors else 0

if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode
python lint_webhook_docs.py artifacts/webhook_catalog.json docs/webhooks
Enter fullscreen mode Exit fullscreen mode

The forbidden list is intentionally crude. It catches common invented guarantees so reviewers are not the first filter. Tune the patterns to the vocabulary your docs actually use, then keep the list in review like any other policy file.

4. Draft examples only from the catalog, not from a repository dump

Prompt a model with webhook_catalog.json and the forbidden-claim list, not with application source, .env files, or production payload samples. The drafting job needs the event names, field types, and required flags. It does not need broker credentials or customer data.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can run this extract-and-draft step as an isolated batch job, which keeps the catalog compiler and the language model on a throwaway workspace instead of a laptop session that still has production files mounted. Do not treat that setup as a quota, hardware, or benchmark claim; it is only a place to compile examples from an already reviewed catalog.

A constrained prompt looks like the following proposal. Replace the catalog body at runtime rather than pasting live events into chat history by hand.

You receive a webhook catalog JSON. Draft one markdown stub per event.
Allowed: event name, version, field table, and one synthetic JSON example.
Forbidden: retry counts, ordering, retention, SLAs, and legal commitments.
If a field is pii_candidate, say "redaction is a signed human claim" and stop.
Do not mention events absent from the catalog.
Enter fullscreen mode Exit fullscreen mode

The model may produce receiver-side walkthroughs and JSON examples that match field names. It must not emit retry counts, ordering promises, or retention periods unless those values already exist as signed fields in the contract file from the next step.

5. Store delivery promises in a signed contract file

Keep owned promises in a second artifact that CI checks for reviewer identity and a calendar date. Missing signatures fail the release even if examples look complete. Proposed shape:

# delivery_contract.yaml — human-owned; do not generate numbers here
events:
  invoice.finalized@2026-09-21:
    delivery: at_least_once
    ordering: none
    signature:
      header: X-Webhook-Signature
      algorithm: hmac-sha256
    retry:
      max_attempts: 8
      backoff: exponential
      initial_seconds: 30
    pii:
      fields: [customer_email]
      retention_days: 30
      redaction: hash_before_log
    sunset: null
    signed_by: "release-owner@example.com"
    signed_on: "2026-09-21"
Enter fullscreen mode Exit fullscreen mode
# lint_delivery_contract.py — proposed example
import json, pathlib, sys, yaml

REQUIRED = ("delivery", "ordering", "signature", "retry", "pii", "signed_by", "signed_on")

def main(catalog_path: str, contract_path: str) -> int:
    events = json.loads(pathlib.Path(catalog_path).read_text())["events"]
    contract = yaml.safe_load(pathlib.Path(contract_path).read_text())["events"]
    errors = []
    for event in events:
        key = f"{event['name']}@{event['version']}"
        row = contract.get(key)
        if not row:
            errors.append(f"unsigned event {key}")
            continue
        for field in REQUIRED:
            if not row.get(field):
                errors.append(f"{key} missing {field}")
        pii_fields = {f["name"] for f in event["fields"] if f["pii_candidate"]}
        claimed = set(row.get("pii", {}).get("fields") or [])
        if pii_fields != claimed:
            errors.append(f"{key} PII mismatch catalog={sorted(pii_fields)} contract={sorted(claimed)}")
    print("\n".join(errors), file=sys.stderr)
    return 1 if errors else 0

if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode

Numbers in this YAML are illustrative placeholders for the workflow, not measured production SLOs. Fill them from broker configuration, legal review, and a named owner. Never ask a model to invent max_attempts or retention_days because those values are operational and contractual, not stylistic.

6. Publish only after catalog, lint, and signatures pass

Join the three artifacts in the docs renderer: catalog tables, drafted examples, and the signed contract block. Pages lacking signed_by and signed_on should render as draft and stay off the public version. A minimal pipeline is:

python compile_webhook_catalog.py > artifacts/webhook_catalog.json
python lint_webhook_docs.py artifacts/webhook_catalog.json docs/webhooks
python lint_delivery_contract.py artifacts/webhook_catalog.json delivery_contract.yaml
Enter fullscreen mode Exit fullscreen mode

If any command exits non-zero, do not promote the docs site. The failure mode you want is a red CI job, not a receiver that built exactly-once logic against a paragraph nobody owned.

Limitations

The registry only sees events that developers remembered to decorate. Dynamic emitters, third-party callbacks, and one-off scripts will not appear until someone registers them. The forbidden-phrase linter will both miss carefully worded guarantees and flag legitimate discussion in design notes, so keep design notes out of the public docs glob.

Schema hashes detect field-shape drift; they do not detect semantic drift. Renaming total_cents without changing the JSON type still requires a human version bump. Synthetic examples can look valid while violating business invariants the catalog does not encode, such as currency-specific rounding.

This workflow does not replace broker documentation, threat modeling, or a data-processing agreement. It only stops a drafting model from publishing delivery language that nobody signed.

Who should not use this approach

Do not use this split if webhooks are an undocumented internal bus with no external receivers. Do not use it if legal has not classified PII fields, because the contract linter will only freeze incomplete claims. Teams without CI ownership will copy the YAML once and let it rot beside generated markdown, which is worse than sparse official docs.

Skip model drafting entirely when the catalog still changes daily during an unreleased redesign. Compile the registry first, sign nothing, and wait until event names stabilize. A free drafting workspace does not reduce the need for a named owner on retry, ordering, and retention.

If your only goal is prettier examples, run the catalog compiler and write the contract by hand. The drafting step is optional; the signature gate is not. For a constrained extract-and-draft pass on an isolated workspace, MonkeyCode can host that job without folding production secrets into the prompt—review the catalog and contract locally before anything goes public.

Top comments (0)