Skip to main content

Webhook request structure explained

A webhook is just an HTTP request, but understanding what goes into that request helps you build better integrations. Whether you are consuming webhooks from a third-party service or designing your own webhook system, the same structural patterns appear again and again: headers that carry metadata and security information, payloads that contain the actual event data, event types that tell you what happened, and event IDs that help you handle duplicates.

This article breaks down each component so you know what to expect when working with webhooks and what decisions matter when designing them.

Headers carry metadata and security

The HTTP headers of a webhook request do more than just describe the content type. They carry information that is critical for processing and validating the request.

The Content-Type header is almost always application/json, though some older systems use application/x-www-form-urlencoded. Knowing the content type tells your endpoint how to parse the body.

Most webhook providers include a signature header that lets you verify the request actually came from them and was not tampered with in transit. The name varies by provider: Stripe uses Stripe-Signature, GitHub uses X-Hub-Signature-256, and providers following the Standard Webhooks spec (including Svix) use webhook-signature. The value is typically an HMAC hash of the request body using a shared secret, combined with a timestamp.

Speaking of timestamps, many providers include a header like webhook-timestamp or embed the timestamp in the signature header. This timestamp is essential for preventing replay attacks where an attacker captures a valid webhook and resends it later. Your verification logic should reject requests with timestamps more than a few minutes old.

You will also often see headers indicating the event type (X-GitHub-Event, X-Shopify-Topic) and a unique identifier for the webhook delivery itself. These headers make it easy to route and log requests without parsing the body.

Payloads contain the event data

The request body is where the actual information lives. A typical webhook payload looks something like this:

{
"type": "invoice.paid",
"id": "evt_1234567890",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"invoice_id": "inv_abc123",
"customer_id": "cust_xyz789",
"amount": 9900,
"currency": "usd"
}
}

The structure usually includes a type field identifying what happened, an ID for the event itself, a timestamp, and a data object containing the relevant details.

Payload design falls on a spectrum between "fat" and "thin" webhooks. A fat webhook includes all the data you need to process the event directly in the payload. A thin webhook just notifies you that something happened and expects you to fetch the details from an API. Fat webhooks are convenient and reduce additional API calls, but they can become stale if the data changes between when the webhook was sent and when you process it. Thin webhooks add latency and API load but guarantee you always work with current data. Many services take a middle ground, including enough data to handle common cases while providing IDs to fetch more if needed.

The payload format should be consistent across event types. If your invoice.paid event has data.invoice_id, your invoice.created event should use the same field name, not data.id or data.invoiceId. Inconsistency creates confusion and bugs.

Event types tell you what happened

The event type is a string that identifies what triggered the webhook. Most systems follow a resource.action naming convention: customer.created, payment.failed, order.shipped. This pattern is intuitive, easy to filter on, and naturally groups related events together.

Some systems go deeper with additional segments: customer.subscription.created or payment.refund.updated. This hierarchy helps when you want to subscribe to all events for a resource (customer.*) or all events of a certain action (*.deleted).

The event type appears both in headers (for easy routing) and in the payload (for processing). Having it in headers lets your web server or API gateway route requests to different handlers without parsing JSON. Having it in the payload ensures all the information you need is in one place.

When designing your own webhook system, choose event types that are specific enough to be useful but general enough to remain stable. invoice.paid is better than invoice.status_changed_to_paid because it is easier to understand and will not break if you refactor your internal status model.

Event IDs enable idempotent processing

Every webhook should include a unique identifier, typically called an event ID or delivery ID. This ID serves several purposes, but the most important is enabling idempotent processing.

Webhooks are delivered with at-least-once semantics. Network failures, timeouts, and retry logic mean you might receive the same webhook multiple times. If your endpoint processes a payment webhook twice, you could charge a customer twice or credit their account twice. Event IDs let you detect and ignore duplicates.

Your endpoint should store the event IDs it has processed (in a database, Redis, or similar store) and check incoming webhooks against this list before processing. If you have seen the ID before, return a 200 response and skip processing.

Incoming webhook with event ID: evt_1234567890

1. Check if evt_1234567890 exists in processed_events table
2. If yes: return 200, do nothing
3. If no: process the event, then store evt_1234567890

The window for storing IDs depends on how long the sender might retry. Keeping IDs for 24-72 hours covers most retry policies while limiting storage requirements.

Some providers distinguish between event IDs and delivery IDs. The event ID identifies the underlying event and stays the same across retries. The delivery ID is unique to each delivery attempt. For idempotency, you want the event ID since the goal is to process each event exactly once regardless of how many times it was delivered.

Putting it together

When you receive a webhook request, your endpoint should handle it in this order: first verify the signature using the headers and your shared secret, rejecting invalid requests immediately. Then check the event ID against your store of processed events to handle duplicates. Parse the payload, using the event type to route to the appropriate handler. Process the event data, then return a 200 response quickly to acknowledge receipt. If processing will take time, acknowledge first and process asynchronously.

Understanding these components makes it easier to work with any webhook provider and to design webhook systems that are secure, reliable, and pleasant to integrate with.