If your webhook platform requires a developer to read a 10-page PDF or open a support ticket to discover which events you send, you've already lost them. The best webhook integrations are self-serve: a developer browses the event catalog, subscribes to the events they care about, tests delivery in a sandbox, and ships — without needing to talk to anyone on your team.
This post covers how to design and implement a webhook event catalog: what belongs in it, how to structure event names, how to expose a discovery API, and the schema decisions that make the difference between a catalog developers trust and one they ignore.
What Is a Webhook Event Catalog?
An event catalog is the authoritative registry of every event type your platform can emit. It serves three audiences:
- ›The integrating developer — discovering what events exist and what data they carry
- ›Your internal team — a single source of truth for event definitions, owners, and deprecation status
- ›Your SDK and tooling layer — a machine-readable schema to generate typed clients, validate payloads, and power IDE autocomplete
A catalog isn't just documentation. It's a contract. Every event type in the catalog is a commitment to backwards compatibility until explicitly deprecated.
Event Naming Conventions
Naming is the most consequential decision you'll make for your event catalog. It's also permanent — once customers have built integrations keyed on order.created, renaming it to orders.create breaks them.
The resource.action Pattern
The most widely adopted convention is dot-separated resource.action:
order.created
order.updated
order.fulfilled
order.cancelled
subscription.created
subscription.renewed
subscription.cancelled
subscription.payment_failed
invoice.issued
invoice.paid
invoice.overdueThis pattern scales well because:
- ›Alphabetical sorting groups related events together
- ›Glob patterns like
order.*let subscribers opt into all events for a resource - ›New actions can be added without changing existing event names
Naming Rules to Enforce from Day One
| Rule | Good | Bad |
|---|---|---|
| Past tense actions | order.created | order.create |
| Lowercase with dots | payment.refunded | payment_refunded, PaymentRefunded |
| Specific over generic | invoice.payment_failed | invoice.error |
| Resource before action | user.deactivated | deactivate.user |
| No internal jargon | subscription.cancelled | sub.churn_initiated |
| Stable across versions | Add, don't rename | Never rename a shipped event type |
Past tense is important. Your webhooks represent things that have already happened. order.created means "an order was created." order.create reads like a command.
Versioning Event Types
Resist adding version numbers to event type names (order.created.v2). This approach creates a proliferation problem — customers who subscribe to order.* miss order.created.v2 unless they explicitly add it.
Instead, version at the envelope level using an api_version field (see below). Event type names stay stable; the payload schema evolves under version control.
The Event Envelope Schema
Every event in your catalog should conform to a consistent envelope. Consistency is what lets developers build a single handler that routes on type rather than per-event parsing logic.
{
"id": "evt_01JQMR9KXV4B2P7TNWY63ZHFD",
"type": "order.fulfilled",
"api_version": "2026-01",
"created_at": "2026-04-02T14:32:01Z",
"livemode": true,
"data": {
"object": {
"id": "ord_8bK2mxP9",
"status": "fulfilled",
"total_cents": 14999,
"currency": "usd",
"fulfilled_at": "2026-04-02T14:31:58Z",
"tracking_number": "1Z999AA10123456784"
},
"previous_attributes": {
"status": "processing"
}
}
}Key fields your envelope must include:
- ›
id— globally unique, stable event identifier. Customers use this for idempotency. - ›
type— the event type from your catalog. Immutable. - ›
api_version— the schema version this event conforms to. Pinned per customer endpoint. - ›
created_at— ISO 8601 timestamp in UTC. Always UTC. - ›
livemode— boolean distinguishing production events from sandbox/test events. - ›
data.object— the current state of the resource. Full object, not just changed fields. - ›
data.previous_attributes— fields that changed, with their prior values. Omit if nothing changed (creation events).
The Discovery API
Documentation is necessary but not sufficient. A machine-readable discovery endpoint lets your SDK, CLI, and developer tooling enumerate available events programmatically.
GET /v1/event-types
GET /v1/event-types/{type}Response for GET /v1/event-types:
{
"data": [
{
"type": "order.created",
"description": "Fired when a new order is created and confirmed.",
"api_version": "2026-01",
"status": "stable",
"schema_url": "https://api.yourapp.com/v1/event-types/order.created/schema",
"example_url": "https://api.yourapp.com/v1/event-types/order.created/example",
"tags": ["orders", "commerce"]
},
{
"type": "subscription.payment_failed",
"description": "Fired when a scheduled subscription payment cannot be collected.",
"api_version": "2026-01",
"status": "stable",
"schema_url": "...",
"example_url": "...",
"tags": ["subscriptions", "billing"]
}
]
}The status field communicates lifecycle state to both humans and tooling:
| Status | Meaning |
|---|---|
stable | Production-ready. Backwards compatible changes only. |
beta | Available but schema may change. Not recommended for critical paths. |
deprecated | Still delivered, but removal is scheduled. Check sunset_date. |
removed | No longer delivered. Historical reference only. |
The schema_url points to a JSON Schema document that describes the exact shape of data.object for that event type. This is what SDK generators, validation libraries, and IDE plugins consume.
Generating Typed Clients from the Catalog
A machine-readable event catalog unlocks typed SDK generation. Here's a minimal Go struct generated from a catalog schema:
// Code generated from event catalog schema v2026-01. Do not edit.
// OrderFulfilledEvent is fired when an order transitions to fulfilled status.
type OrderFulfilledEvent struct {
ID string `json:"id"`
Type string `json:"type"` // always "order.fulfilled"
APIVersion string `json:"api_version"`
CreatedAt time.Time `json:"created_at"`
Livemode bool `json:"livemode"`
Data struct {
Object struct {
ID string `json:"id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
Currency string `json:"currency"`
FulfilledAt time.Time `json:"fulfilled_at"`
TrackingNumber string `json:"tracking_number,omitempty"`
} `json:"object"`
PreviousAttributes *struct {
Status string `json:"status,omitempty"`
} `json:"previous_attributes,omitempty"`
} `json:"data"`
}Generating typed structs from your event catalog achieves two things: it gives developers a strongly-typed interface to program against, and it makes breaking changes immediately visible as compile errors in consumer code. When you add a required field to OrderFulfilledEvent, any consumer that doesn't handle the new field gets a signal — not a silent runtime failure.
If you're building SDKs, the event catalog JSON Schema feeds directly into tools like quicktype or openapi-generator to produce idiomatic types in Go, TypeScript, Python, and PHP.
Documenting Events That Developers Actually Read
Auto-generated API docs are necessary but rarely sufficient. The documentation your developers actually use has four components:
1. A trigger description that's precise about when the event fires
Not: "Fired when an order is updated."
Yes: "Fired when an order transitions from processing to fulfilled. Not fired for other status changes."
Ambiguous trigger descriptions generate support tickets. Be specific about what conditions cause each event and what conditions do not.
2. Example payloads that come from real data
Never write example payloads by hand. Generate them from your test suite. Hand-written examples drift from the actual schema within weeks, and developers who copy-paste an example that doesn't match real delivery lose trust fast.
3. Explicit documentation of nullable and optional fields
tracking_number (string, optional)
Present when a tracking number has been assigned by the carrier.
Null if the order ships from a location that does not provide tracking.Developers write code against every documented field. If tracking_number can be absent and you don't say so, every customer who accesses it without a null check has a latent bug.
4. A changelog linked to each event type
order.fulfilled — Changelog
2026-01-15 Added tracking_number field (optional).
2025-09-03 Added fulfilled_at timestamp field.
2025-04-01 Event introduced.Developers who integrate once and then come back six months later need to know what changed. A per-event changelog is the lowest-effort way to give them that.
Subscription Management in the Catalog UI
An event catalog that only shows documentation is half-built. The highest-value improvement is letting developers subscribe to event types directly from the catalog UI, without touching an API.
The workflow looks like this:
- ›Developer browses the catalog, reads
subscription.payment_failed - ›They click "Subscribe" and select their registered webhook endpoint
- ›The UI creates the route:
subscription.payment_failed→https://api.theirdomain.com/webhooks - ›A test event is sent immediately so they can verify receipt
- ›The subscription appears in their dashboard with delivery metrics
This collapses the integration cycle from "read docs → write API call → test → debug" to "browse catalog → click → verify."
GetHook's routing model maps directly to this pattern. Each route connects an event type pattern (including wildcards like subscription.*) to a destination, with independent retry and delivery tracking per subscription.
Testing Catalog Events in Isolation
Every event type in your catalog should be triggerable on demand in a sandbox environment. This is not the same as a general "test mode" — it's per-event-type test delivery.
# Trigger a test order.fulfilled event to endpoint abc123
curl -X POST https://api.yourapp.com/v1/event-types/order.fulfilled/test \
-H "Authorization: Bearer hk_test_..." \
-H "Content-Type: application/json" \
-d '{"destination_id": "dst_abc123"}'The response should include the delivered event ID and the HTTP response your endpoint returned. If delivery failed, include the error and the raw response body.
This pattern lets developers iterate on their webhook handler code without having to trigger real business events in a staging environment. It also powers catalog-integrated testing in CI — your test suite can call the test endpoint for each event type your integration handles and assert that your handler returns 200.
Keeping the Catalog Honest
A catalog that drifts from the actual payloads your system emits is worse than no catalog. Customers who rely on a field that was silently removed have production failures; customers who never find a useful field because it's undocumented leave features on the table.
The simplest mechanism: run a contract test on every deploy that compares a live event sample against the catalog's JSON Schema for that event type. If a field appears in the live payload that isn't in the schema, the build fails. If a required field in the schema is missing from the payload, the build fails.
func TestEventCatalogContractCompliance(t *testing.T) {
catalog := loadEventCatalog("catalog/schemas")
for _, eventType := range catalog.EventTypes {
t.Run(eventType.Type, func(t *testing.T) {
example := loadExamplePayload(eventType.Type)
schema := catalog.SchemaFor(eventType.Type)
errs := schema.Validate(example)
if len(errs) > 0 {
t.Errorf("event %s fails catalog schema: %v", eventType.Type, errs)
}
})
}
}This test runs in under a second and prevents catalog drift at the cheapest possible point: before deployment.
A well-maintained event catalog is one of the highest-leverage investments you can make in your platform's developer experience. It reduces integration time, cuts support load, and makes your webhook platform feel trustworthy — the kind of API developers recommend to colleagues.
If you want to see how GetHook handles event routing and delivery tracking as the infrastructure layer under your event catalog, start here.