How to Design Webhook Payloads Customers Can Trust
A trustworthy webhook payload has a stable envelope (event ID, type, API version, timestamp, and data), a versioning rule that only adds fields, an idempotency key so retries never repeat a side effect, and an HMAC signature the receiver verifies before trusting the body.
A webhook payload is the JSON body you POST to a customer's URL to tell them something happened in your system, and the shape you pick on day one is the shape you are stuck supporting once someone builds a production integration against it. Rename a field, drop one that looked unused, or reorder a nested object, and you find out about the breakage from a support ticket, not a test suite. The fix is the one API design already solved: pick a stable envelope, version it deliberately, and make every delivery replayable and idempotent before a real customer ever receives one.
CanHook is a webhook inspector and relay/transform pipeline built for the receiving side of this problem — capturing, replaying, and forwarding HTTP requests. Teams that operate a webhook receiver day to day tend to notice the same design mistakes on the sending side, because a payload that's hard to consume is usually one nobody actually looked at before it shipped.
What Should Every Webhook Payload Include?
Every webhook payload needs five things regardless of what event it describes: a unique event ID, an event type, an API version, a timestamp, and a data object scoped to that one event. Skip the ID and a receiver has no way to tell a retried delivery from a genuinely new event. Skip the version and you cannot change the shape later without a breaking change for every subscriber at once.
| Field | Type | Example | Why it matters |
|---|---|---|---|
| id | string | evt_01hxyz9f2k | Lets a receiver deduplicate retried deliveries |
| type | string | invoice.paid | Tells the handler which code path to run |
| api_version | string | 2026-01-01 | Pins the shape so you can change it later without breaking old integrations |
| created_at | ISO 8601 timestamp | 2026-09-01T14:32:07Z | Lets the receiver reject stale or replayed requests outside a tolerance window |
| data | object | { "invoice_id": "inv_9f2k" } | Holds the event-specific fields, nested so the envelope stays constant |
How Do You Version Events Without Breaking Existing Subscribers?
You version webhook events the same way you version a public API: additively, with a stated deprecation window, never in place. A field that was a string yesterday cannot become an object today under the same event type — that isn't a version bump, it's a different event, and it needs its own type value or a new api_version.
- Add new fields; never rename or repurpose an existing one.
- Give every event a distinct
typestring so consumers can add handlers without touching existing ones. - Stamp every payload with
api_version, and bump it only for breaking changes, not additions. - Keep the previous version's shape live for a stated deprecation window — 30 to 90 days is typical — once a new one ships.
- Publish a changelog per event type so integrators can diff two versions without reverse-engineering payloads by hand.
Most of this is process discipline, not code. The one part worth automating is the changelog: generate it from whatever schema or type definitions already define your events, so it can't drift from what you actually send.
Why Idempotency Keys Matter as Much as Signatures
At-least-once delivery is the default assumption for any webhook system worth trusting: your queue will redeliver on a timeout even when the first attempt actually succeeded downstream, and your own retry logic will do the same thing after a 5xx that fired once the receiver's database write had already committed. An idempotency key — usually the same id field from the envelope — lets a receiver's handler check whether it already processed this exact event before doing anything with a side effect, like charging a card or sending a notification twice. Stripe's webhook documentation recommends exactly this: store the event ID and skip any event you've already handled.
{
"id": "evt_01hxyz9f2k",
"type": "invoice.paid",
"api_version": "2026-01-01",
"created_at": "2026-09-01T14:32:07Z",
"data": {
"invoice_id": "inv_9f2k",
"amount_cents": 4900,
"currency": "usd"
}
}If you want the receiving side of this same problem worked through end to end, see how duplicate webhook deliveries are typically handled once they land.
How Do You Handle Delivery Failures on the Sending Side?
Retry on your own schedule, not the receiver's. Exponential backoff with a hard cap — a handful of attempts over a few hours, not days — keeps a struggling downstream from getting hammered while still giving a transient failure, like a deploy or a database failover, a real chance to succeed on retry. A 4xx response almost always means retrying is pointless: the receiver rejected the request rather than failing to process it, so an identical retry fails identically. A 5xx or a timeout is the case actually worth retrying.
Sign every payload with an HMAC of the raw body plus a timestamp, the same approach GitHub and Stripe both use, and rotate the signing secret on a schedule rather than only after a suspected leak — see how to rotate a signing secret with zero downtime if you haven't built that yet.
What Should You Log for Every Delivery Attempt?
Log enough to answer a support ticket without redeploying anything: the event ID, the destination URL, the HTTP status code returned, the first few hundred characters of the response body, the attempt number, and the duration. Don't keep the full request or response body long-term if either can contain customer data — a truncated snippet is enough for debugging and doesn't turn your delivery log into a second data-retention problem. The docs on capturing webhooks cover how retention windows work day to day if you want a model to copy.
Testing Outbound Webhooks Before Customers See Them
The manual version of this is a local script that POSTs to your own dev server and a log line you delete before committing — it works until you need to see the exact headers your HTTP client actually sent, not the ones you assume it sent, or until you want to replay the same payload five times while you fix a handler bug. Pointing your sender at a CanHook catch URL during development captures the raw method, headers, and body exactly as they left your code, and you can replay that captured request against your local server as many times as you need without rebuilding the payload each time. See what a captured request looks like on the features page.
const crypto = require('crypto');
function signPayload(rawBody, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${rawBody}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
// Attach as a header when you POST the webhook:
// X-Webhook-Signature: t=1735689127,v1=8f3a2c...The obvious objection is that this is one more account to configure when you could add logging middleware to a receiver you already control. That's fair for a receiver you own, but most outbound webhooks first fire during local development, against an integration you don't own yet or a teammate's endpoint that isn't finished. A hosted catch URL skips that setup entirely: no server to run, no port to forward, no tunnel to keep alive, and the free tier's 500 captures a day covers a normal dev cycle before you're testing against a real customer endpoint.
Create a free CanHook endpoint, point your webhook sender at its catch URL, and send one real payload from your own code. You'll see the exact request it produced, headers included, within a second — sign up free and try it against whatever you're building next.
Frequently asked questions
What fields should a webhook payload always include?
A stable webhook payload includes a unique event ID, an event type string, an API version, an ISO 8601 timestamp, and a data object scoped to that event. The ID lets receivers deduplicate retried deliveries, and the version lets you change the payload shape later without breaking every existing integration at once.
How do you version webhook events without breaking existing subscribers?
Version additively: never rename, remove, or repurpose an existing field. Stamp every payload with an api_version, bump it only for breaking changes, and keep the previous version's shape live for a stated deprecation window, typically 30 to 90 days, before removing it.
What is an idempotency key in a webhook context?
An idempotency key is a unique identifier, usually the event ID itself, that a receiver stores after processing a webhook so it can recognize and skip a retried delivery of the same event instead of repeating a side effect like charging a card twice.
Should webhook payloads include a signature?
Yes. Sign the raw request body with an HMAC keyed to a shared secret, include a timestamp in the signed value, and have the receiver reject any request outside a short tolerance window. This stops both forged payloads and old, captured requests from being replayed as new ones.
How long should you keep failed webhook delivery logs?
Keep enough to debug a support ticket without redeploying: event ID, destination, status code, a short response snippet, attempt number, and duration. Avoid storing full request or response bodies long-term if they can contain customer data, since that turns a delivery log into a retention liability.
Can you test outbound webhooks before sending them to real customers?
Yes. Point your webhook sender at a hosted catch URL during development to capture the exact method, headers, and body your code produced, then replay that captured request against your local server as many times as needed while you fix a handler.