The Anatomy of a Good Webhook Payload
Most webhook integrations fail not because of network issues or authentication problems, but because the payload itself is poorly designed. I've seen teams spend days reverse-engineering undocumented payloads, writing fragile parsers for inconsistent event formats, and building workarounds for missing fields. A well-structured payload eliminates all of that friction.
At minimum, every webhook payload needs four things: an event type that tells the consumer what happened, a unique identifier for deduplication, a timestamp for ordering, and the actual data. Here is what a poorly designed payload looks like compared to one that respects the consumer's time.
Bad payload (missing context, ambiguous structure):
{
"order_id": "ord_8xk2m",
"status": "paid",
"amount": 4999
}
Good payload (self-describing, complete, and unambiguous):
{
"id": "evt_1a2b3c4d5e",
"type": "order.payment_succeeded",
"api_version": "2026-01-15",
"created_at": "2026-01-28T14:32:01.042Z",
"data": {
"order_id": "ord_8xk2m",
"status": "paid",
"amount": 4999,
"currency": "eur",
"customer_id": "cus_9xj3n"
}
}
The second payload tells the consumer exactly what happened, when it happened, and which version of the API produced it. The consumer doesn't need to guess. That difference in clarity compounds across thousands of events per day.
Event Naming Conventions
Event names are the single most important field in your payload. They determine how consumers route, filter, and handle events. A sloppy naming scheme creates confusion that never goes away, because renaming events after consumers depend on them is nearly impossible.
The pattern that works best is resource.action in past tense: order.created,
payment.failed, invoice.finalized. Past tense signals that the event already
happened, since this is a notification, not a request. Some teams use present tense like
order.create, but this reads like a command rather than a fact. Stick with past tense.
For nested resources, extend the pattern: order.item.added,
subscription.invoice.paid. Keep it to three levels maximum.
A practical taxonomy for an e-commerce system might look like this:
order.created,order.updated,order.cancelled,order.fulfilledpayment.succeeded,payment.failed,payment.refundedcustomer.created,customer.updated,customer.deletedsubscription.started,subscription.renewed,subscription.cancelled
Notice the consistency: every event follows the same grammar. A developer who sees
payment.succeeded can predict that payment.failed also exists. That
predictability is worth protecting.
The Envelope Pattern
Mixing metadata with business data in the same flat object is a mistake I've seen repeatedly. When the event type, timestamp, and delivery metadata live alongside order amounts and customer emails, consumers have to know which fields are "about the event" and which are "the event itself." The envelope pattern solves this by creating a clear separation.
The outer envelope carries metadata: event type, unique ID, timestamp, API version. The inner
data field carries the business payload. This structure is predictable across every
event type, so consumers can write a single generic handler for envelope parsing and then
dispatch on the event type.
interface WebhookEnvelope<T = unknown> {
id: string; // unique event ID
type: string; // e.g., "order.created"
api_version: string; // e.g., "2026-01-15"
created_at: string; // ISO 8601 timestamp
delivery_id: string; // unique per delivery attempt
sequence: number; // ordering within the stream
data: T; // the actual business payload
}
// Typed for a specific event
interface OrderCreatedEvent {
order_id: string;
customer_id: string;
amount: number;
currency: string;
items: Array<{ sku: string; quantity: number; price: number }>;
}
type OrderCreatedWebhook = WebhookEnvelope<OrderCreatedEvent>;
With this approach, a consumer's routing logic becomes trivial: parse the envelope, switch on
type, and cast data to the appropriate shape. Every event shares
the same outer structure regardless of what business data it carries.
Idempotency Keys: Event ID vs Delivery ID
Webhooks get delivered more than once. Networks drop connections, consumers return 500 errors during deploys, and retry logic kicks in. If a consumer processes the same event twice, they might charge a customer twice or send duplicate emails. Every event needs a unique identifier that consumers can use for deduplication.
There are two identifiers that serve different purposes. The event ID identifies the event itself: if the same business event happens, it always has the same ID. The delivery ID identifies a specific delivery attempt, so each retry gets a new delivery ID but carries the same event ID.
Consumers should deduplicate on the event ID. If they see evt_1a2b3c a second time, they
skip processing. The delivery ID is useful for debugging: "this event was delivered 3 times, with
delivery IDs dlv_x, dlv_y, dlv_z, and the consumer
acknowledged dlv_z."
For generating event IDs, a combination of a prefix and a random string works well:
evt_ followed by 20 characters of base62. UUIDs work too, but prefixed IDs are easier
to spot in logs. Never use auto-incrementing integers because they leak information about your event volume
and create collisions in distributed systems.
Timestamps and Ordering
A single timestamp is not enough. You need at least two: created_at (when the webhook
event was created in your system) and occurred_at (when the underlying business event
actually happened). These can differ by seconds or even minutes. A payment might succeed at 14:32:01
but the webhook event might not be created until 14:32:04 because of queue processing delays.
Clock skew makes timestamp-based ordering unreliable across distributed services. Two events from
different servers might have timestamps 200ms apart, but that gap could be clock drift rather than
actual ordering. In practice, I recommend including a monotonically increasing sequence
number alongside timestamps. The sequence gives consumers a definitive order without depending on
clock accuracy.
Always use ISO 8601 with UTC and millisecond precision: 2026-01-28T14:32:01.042Z.
Unix timestamps are harder to read in logs, and second-level precision loses information when
events happen close together. Milliseconds matter.
Full Resources vs IDs Only
This is the most debated design decision in webhook payloads. Should you send the complete resource (full order object with all fields) or just the resource ID and let the consumer fetch the rest via your API?
Including the full resource means the consumer has everything they need in one delivery. No extra API call, no latency, no rate-limit concerns. For 80% of use cases, this is the right approach. The downsides: payloads get larger (a fully expanded order with line items might be 5-10KB), and the data represents a snapshot at event creation time, and by the time the consumer processes it, the resource might have changed.
Sending only IDs produces tiny payloads and guarantees freshness because the consumer always fetches the current state. But it creates a hard dependency on your API's availability. If your API is down during a webhook processing window, the consumer is stuck. It also multiplies the load on your API because every webhook becomes a webhook plus an API call.
My recommendation: send the full resource by default, and include the resource ID so consumers can fetch a fresh copy if needed. For large or deeply nested resources, include the top-level fields inline and provide IDs for nested objects. This gives consumers the best of both worlds without bloating payloads.
Schema Evolution Rules
Your webhook payloads will change over time. New fields get added, business logic evolves, and requirements shift. The question is whether those changes break existing consumers. Following a few strict rules prevents 90% of breaking changes.
Rule 1: Add fields freely. New fields should never break a well-written consumer. Any consumer that fails because of an unexpected field has a brittle parser. This is your safest evolution path.
Rule 2: Never remove fields. If a consumer depends on order.shipping_address,
removing it will break their integration. Deprecate fields by documenting them as deprecated, but
keep sending them. You can stop populating them (send null) after a long deprecation
window, 12 months minimum.
Rule 3: Never change a field's type. If amount was a number, it
stays a number. If you need to change it from cents (integer) to a decimal string, add a new
field like amount_decimal and keep the original.
Rule 4: New optional fields should be nullable. When adding a field that
might not have a value for all events, make it explicitly null rather than
omitting it. Consumers can check for null; checking for a missing key is
language-dependent and error-prone.
Here is a before-and-after example of safe schema evolution:
// Version 2026-01-15 (original)
{
"type": "order.created",
"data": {
"order_id": "ord_8xk2m",
"amount": 4999,
"currency": "eur"
}
}
// Version 2026-03-01 (evolved, no breaking changes)
{
"type": "order.created",
"data": {
"order_id": "ord_8xk2m",
"amount": 4999,
"amount_decimal": "49.99",
"currency": "eur",
"tax_amount": 950,
"metadata": null
}
}
The evolved payload added three new fields (amount_decimal, tax_amount,
metadata) without removing or changing anything. A consumer written against the
original version will keep working without modification.
When you need to make a truly breaking change (restructuring the payload, changing event semantics,
or removing fields), that is what API versioning is for. Include the api_version in your
envelope and let consumers pin to a specific version. Ship the breaking change under a new version
and give consumers a migration window.
Batch Event Payloads
Sending one HTTP request per event is clean and simple, but it falls apart at high volumes. If your system generates 10,000 events per minute, that is 10,000 outbound HTTP connections. Batching multiple events into a single delivery reduces connection overhead, cuts down on TLS handshakes, and lets consumers process events in bulk.
A batch envelope wraps an array of individual events, each with their own ID and type. The batch itself gets a delivery ID and metadata:
{
"batch_id": "bat_7f8g9h",
"delivery_id": "dlv_3k4l5m",
"created_at": "2026-01-28T14:32:01.042Z",
"events": [
{
"id": "evt_1a2b3c",
"type": "order.created",
"sequence": 1041,
"data": { "order_id": "ord_8xk2m", "amount": 4999 }
},
{
"id": "evt_4d5e6f",
"type": "payment.succeeded",
"sequence": 1042,
"data": { "payment_id": "pay_2n3o4p", "amount": 4999 }
}
]
}
Within a batch, events should be ordered by sequence number. Consumers process them in order from first to last. If a consumer fails on the third event in a batch of ten, the entire batch should be retried. Partial acknowledgment adds complexity that rarely pays off.
When should you batch? If your throughput exceeds roughly 50 events per second to a single consumer endpoint, batching starts making sense. Below that threshold, individual delivery is simpler to implement and debug. A good batch size is 10-100 events or 5 seconds of buffering, whichever comes first. Going above 500 events per batch risks timeouts on the consumer side.
Putting It All Together
Good webhook payload design is a form of API design that just happens asynchronously.
The principles are the same: be consistent, be explicit, and never break existing consumers.
Use the envelope pattern to separate metadata from data. Name events with the
resource.action pattern in past tense. Include both an event ID and a delivery
ID. Send full resources with IDs as fallbacks. Evolve your schema by adding fields,
never removing them. And when volume demands it, batch events with clear ordering guarantees.
The effort you invest in payload design pays dividends every time a new consumer integrates with your system. A developer who can read your payload and understand it without checking documentation is a developer who ships their integration in hours instead of days.