Back to Blog
webhooksdeveloper experienceAPI designdocumentation

Designing a Webhook Event Catalog for Developer Self-Service

A well-designed event catalog lets developers discover, understand, and subscribe to webhook events without filing a support ticket. Here's how to structure one — from naming conventions to discovery APIs.

J
Jordan Okafor
Senior Backend Engineer
April 2, 2026
9 min read

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:

  1. The integrating developer — discovering what events exist and what data they carry
  2. Your internal team — a single source of truth for event definitions, owners, and deprecation status
  3. 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.overdue

This 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

RuleGoodBad
Past tense actionsorder.createdorder.create
Lowercase with dotspayment.refundedpayment_refunded, PaymentRefunded
Specific over genericinvoice.payment_failedinvoice.error
Resource before actionuser.deactivateddeactivate.user
No internal jargonsubscription.cancelledsub.churn_initiated
Stable across versionsAdd, don't renameNever 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.

json
{
  "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:

json
{
  "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:

StatusMeaning
stableProduction-ready. Backwards compatible changes only.
betaAvailable but schema may change. Not recommended for critical paths.
deprecatedStill delivered, but removal is scheduled. Check sunset_date.
removedNo 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:

go
// 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:

  1. Developer browses the catalog, reads subscription.payment_failed
  2. They click "Subscribe" and select their registered webhook endpoint
  3. The UI creates the route: subscription.payment_failedhttps://api.theirdomain.com/webhooks
  4. A test event is sent immediately so they can verify receipt
  5. 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.

bash
# 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.

go
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.

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.