Most webhook design mistakes get made in the first hour and paid for eighteen months later, when the payload shape needs to change and half your consumers are still parsing the old one. Designing a webhook is really designing a contract you can't easily renegotiate — unlike an API you control on both ends, you often don't know who's listening, so the cost of getting the envelope wrong is higher than it looks on day one.
Version the envelope, not just the payload
The event type and the schema version belong in the envelope, separate from the data. A payload like {"event": "invoice.paid", "version": 2, "data": {...}} lets you ship a v2 of the data shape without breaking every consumer still parsing v1 — they can check the version field and either handle it or ignore the event. Baking the version into the event name instead (invoice.paid.v2) works too, but it multiplies the number of event types consumers have to subscribe to. Pick one scheme early; retrofitting versioning onto an unversioned webhook means every existing integration is implicitly "version 1" with no way to signal that.
{
"id": "evt_8f2a1c",
"event": "invoice.paid",
"version": 2,
"created_at": "2026-07-28T09:14:03Z",
"data": {
"invoice_id": "inv_4471",
"amount_cents": 250000,
"currency": "USD",
"customer_id": "cus_9910"
}
}
Design for at-least-once, not exactly-once
Almost no webhook system can honestly promise exactly-once delivery — network timeouts mean the sender can't always tell if a delivery succeeded, so the safe default is to retry, which means the consumer will occasionally see the same event twice. Don't fight this by trying to build exactly-once delivery; design the payload so duplicates are cheap to detect instead. Every event needs a stable, unique ID that the consumer can use as an idempotency key, and the send side needs to preserve that ID across retries rather than minting a new one each attempt.
If a retry regenerates the event ID, every downstream idempotency check breaks silently. Generate the ID once, when the event is first queued for delivery, and reuse it on every retry attempt for that same event.
Include the fields consumers will actually need
A webhook that only says "something changed on object X" forces every consumer to make a follow-up API call to find out what changed, which doubles your request volume and adds latency to their processing. Include enough of the changed object in the payload that most consumers can act without a callback — but don't try to embed the entire object graph either, since that couples your webhook schema to your internal data model in ways that make future changes harder. A reasonable middle ground: include the top-level resource plus the specific fields that changed, and provide a reference ID for anything the consumer needs to fetch fresh.
Only promise the delivery guarantees you can keep
Documentation that says "we guarantee delivery" and a system with no retry queue are on a collision course. Decide upfront: how many retry attempts, over what time window, with what backoff, and what happens after the last attempt fails (dead-letter it, email the customer, disable the endpoint). Write that down in the public docs in specific terms — "we retry for up to 24 hours with exponential backoff, after which the event is dropped and visible in your dashboard's failed-events log" is a guarantee you can actually keep, unlike an unqualified "guaranteed delivery."
If events can arrive out of order (a delete before its corresponding create, for instance, during a retry storm), tell consumers explicitly and give them a sequence number or timestamp to reorder on their end. Silently assuming in-order delivery is one of the most common webhook integration bugs, on both sides.
Sign requests and include a timestamp from day one
Retrofitting HMAC signatures onto a webhook that's already in the wild means asking every existing consumer to change their verification code on your timeline, not theirs. Ship signing from the first version, even if it feels like overkill for an internal beta — an X-Webhook-Signature header computed over a timestamp-plus-body string, verified against a shared secret, closes off replay attacks and lets consumers reject forged requests before they touch application logic.
Wrapping up
The decisions that are cheap to make before launch — an explicit version field, a stable event ID, honest delivery-guarantee language, signing from day one — are the same decisions that are painful to retrofit once real consumers depend on the current shape. Webhook design isn't complicated, but it is unforgiving of decisions deferred past the first integration.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.