Most webhook integrations treat headers as an afterthought. The payload gets carefully schema-versioned, documented, and tested. The headers? Usually a signature and maybe a content type. Then six months later, your on-call engineer is staring at a delivery failure at 2 AM, the only context available is a raw HTTP log, and there's no way to tell which source sent the event, what version the payload is, or whether this is a retry of something that already succeeded.
Good header design doesn't cost much. Done right, it makes the difference between a five-minute debug session and a two-hour incident.
This post covers which headers to send on outbound webhooks, which to capture on inbound webhooks, and how to use them for deduplication, routing, and observability.
The Core Header Set
If you're building a webhook system — whether you're a platform sending events to customers, or a gateway forwarding inbound webhooks to internal services — these headers should be non-negotiable:
| Header | Purpose | Example value |
|---|---|---|
Webhook-Id | Globally unique identifier for this delivery attempt | wh_01HXYZ3ABCDEF |
Webhook-Timestamp | Unix timestamp of original event creation | 1744536000 |
Webhook-Signature | HMAC-SHA256 signature for payload authentication | t=1744536000,v1=abc123... |
Content-Type | Always application/json for structured payloads | application/json |
Webhook-Event-Type | Machine-readable event type | payment.completed |
Webhook-Retry-Count | Number of times this event has been attempted | 0, 1, 2 |
Webhook-Source | Identifier for the origin system | payments-service |
Webhook-Api-Version | Schema version of the payload | 2026-04-01 |
None of these are exotic. Each one answers a different operational question, and the cost to include them is essentially zero.
Webhook-Id: The Single Most Important Header
If you implement only one header beyond the signature, make it Webhook-Id. It's a stable, unique identifier for a specific event — not for a delivery attempt.
The distinction matters. If an event is retried three times before it succeeds, all three delivery attempts carry the same Webhook-Id. The consumer can use it to deduplicate:
func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
webhookID := r.Header.Get("Webhook-Id")
if webhookID == "" {
http.Error(w, "missing Webhook-Id", http.StatusBadRequest)
return
}
// Check if we've already processed this event
processed, err := h.store.IsProcessed(r.Context(), webhookID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if processed {
// Already handled — return 200 to prevent further retries
w.WriteHeader(http.StatusOK)
return
}
// Process the event, then mark as processed
if err := h.processEvent(r.Context(), r.Body); err != nil {
http.Error(w, "processing failed", http.StatusInternalServerError)
return
}
if err := h.store.MarkProcessed(r.Context(), webhookID); err != nil {
// Log but don't fail — the event was processed successfully
log.Printf("failed to mark webhook %s as processed: %v", webhookID, err)
}
w.WriteHeader(http.StatusOK)
}The deduplication store is a simple table: event ID as the primary key, processed timestamp, and optionally the event type for debugging.
CREATE TABLE processed_webhooks (
webhook_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Clean up old records (optional, but prevents unbounded growth)
CREATE INDEX ON processed_webhooks (received_at);Without Webhook-Id, every retry is indistinguishable from a new event. Consumers are left guessing whether to re-process or not, and the "safe" choice — always re-process — means double-charging customers, double-sending emails, or double-provisioning resources.
Webhook-Timestamp and Replay Window Enforcement
The Webhook-Timestamp header exists for a specific security reason: preventing replay attacks. If someone captures a valid webhook request, they could replay it hours later. By checking that the timestamp is recent, you make replayed events ineffective.
The conventional window is five minutes:
const replayWindowSeconds = 300
func validateTimestamp(r *http.Request) error {
tsHeader := r.Header.Get("Webhook-Timestamp")
if tsHeader == "" {
return fmt.Errorf("missing Webhook-Timestamp header")
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return fmt.Errorf("invalid Webhook-Timestamp: %v", err)
}
eventTime := time.Unix(ts, 0)
diff := time.Since(eventTime).Abs()
if diff > replayWindowSeconds*time.Second {
return fmt.Errorf("webhook timestamp too old or too far in future: %v", diff)
}
return nil
}One practical issue: legitimate webhook replays (triggered by your team after a bug fix, or by the provider after a confirmed outage) will also fail this window check. The solution is to either widen the window for explicit replay requests, or to skip the timestamp check when Webhook-Retry-Count indicates a programmatic retry from the gateway rather than a replay.
GetHook includes a Webhook-Timestamp on every delivery and uses it as part of the signed message (t=<unix>,v1=<hex>) so the signature and timestamp are coupled — you cannot swap the timestamp without invalidating the signature.
Webhook-Retry-Count: Enabling Retry-Aware Consumers
Not all processing logic should run on every attempt. Consider:
- ›Notification emails: You don't want to send the same failure notification email on retry #3 if you already sent it on retry #1.
- ›Metrics recording: You might want to record a metric on the first attempt but not on retries, to avoid double-counting.
- ›Idempotency: Your deduplication logic might be database-backed and introduce latency. On retry #0 (first attempt), you can skip the deduplication check for a performance gain, then enable it on retries.
retryCount, _ := strconv.Atoi(r.Header.Get("Webhook-Retry-Count"))
isRetry := retryCount > 0
if !isRetry {
// Fast path: assume first delivery is unique, skip dedup check
h.process(r.Context(), event)
} else {
// Slow path: verify this isn't a duplicate before processing
if !h.isDuplicate(r.Context(), webhookID) {
h.process(r.Context(), event)
}
}This is a performance optimization, not a correctness shortcut. Your handler still needs to be idempotent — but you can delay the cost of proving idempotency until it actually matters.
Webhook-Api-Version: Decoupling Producer and Consumer Release Cycles
The Webhook-Api-Version header is the single most important header for long-term maintainability of webhook integrations. Without it, every payload schema change is a potential breaking change for every consumer simultaneously.
The model: the producer includes a version identifier on every request. Consumers gate their behavior on that version. When the producer releases a new schema, it routes to the appropriate handler based on the header:
func (h *EventHandler) routeByVersion(w http.ResponseWriter, r *http.Request, body []byte) {
version := r.Header.Get("Webhook-Api-Version")
switch version {
case "2026-04-01":
h.handleV20260401(w, r, body)
case "2025-10-01":
h.handleV20251001(w, r, body)
default:
// Unknown version — fail loudly so the producer knows
http.Error(w, fmt.Sprintf("unsupported API version: %s", version), http.StatusBadRequest)
}
}This enables producers to deprecate old schema versions on a schedule while consumers migrate at their own pace. It's the same pattern Stripe uses — every API request includes a Stripe-Version header, and Stripe routes to the correct behavior per customer.
The version value should be a date string in YYYY-MM-DD format. Avoid semantic version numbers (v1.2.3) — they imply a precision of change that's hard to maintain. A date anchors the version to the schema as it existed on that date, which is unambiguous.
Capturing Headers on Inbound Webhooks
When you're receiving webhooks from third-party providers, capturing all headers — not just the ones you expect — is cheap insurance against future debugging pain.
Third-party providers often include useful metadata in headers you won't think to look for until you need them:
| Provider | Notable non-standard headers |
|---|---|
| Stripe | Stripe-Signature, Idempotency-Key |
| GitHub | X-GitHub-Event, X-GitHub-Delivery, X-GitHub-Hook-ID |
| Shopify | X-Shopify-Topic, X-Shopify-Shop-Domain, X-Shopify-Webhook-Id |
| Twilio | X-Twilio-Signature, I-Twilio-Idempotency-Token |
| PagerDuty | X-Webhook-Id, X-Webhook-Subscription-Id |
If you're running a webhook gateway, store the full header set with each inbound event. A few hundred bytes of header data per event is negligible storage cost compared to the debugging value when you need to reconstruct exactly what a provider sent three days ago.
func headersToMap(h http.Header) map[string]string {
result := make(map[string]string, len(h))
for k, v := range h {
// Store the first value for each header key
if len(v) > 0 {
result[k] = v[0]
}
}
return result
}Store this as JSONB in Postgres alongside the raw body. When a customer asks "did you receive the payment.succeeded event from Stripe yesterday?", you can query by Stripe-Signature prefix or X-GitHub-Delivery UUID and show them exactly what arrived.
Header Size Budgets
Headers aren't free. HTTP/1.1 has no hard header size limit in the spec, but practical limits exist:
- ›nginx default: 8 KB for all headers combined
- ›AWS ALB: 16 KB per request
- ›Cloudflare: 32 KB per request
- ›Most HTTP clients: 8–32 KB depending on implementation
For outbound webhook delivery, your header set will typically be under 1 KB — comfortably within any limit. The risk is on inbound: if a provider sends you a large JWT or a base64-encoded HMAC with a long payload, a single header can approach 4–8 KB. Set appropriate limits and test against real provider payloads.
| Header budget concern | Practical limit |
|---|---|
Webhook-Signature header | ~200 bytes (Stripe-format) |
| Provider JWT auth headers | 1–4 KB depending on claims |
| Total safe header budget | Under 8 KB to be universally compatible |
Putting It Together: A Reference Implementation
Here's a minimal but complete set of outbound headers for a webhook delivery system:
func setWebhookHeaders(w http.Header, eventID, eventType, source, apiVersion string, retryCount int, timestamp time.Time, sig string) {
w.Set("Content-Type", "application/json")
w.Set("Webhook-Id", eventID)
w.Set("Webhook-Timestamp", strconv.FormatInt(timestamp.Unix(), 10))
w.Set("Webhook-Event-Type", eventType)
w.Set("Webhook-Source", source)
w.Set("Webhook-Api-Version", apiVersion)
w.Set("Webhook-Retry-Count", strconv.Itoa(retryCount))
w.Set("Webhook-Signature", sig)
}The signature (Webhook-Signature) is computed over t=<timestamp>.<raw body> using HMAC-SHA256, which binds the timestamp to the payload — preventing independent timestamp forgery.
Every header in this set earns its place: Webhook-Id enables deduplication, Webhook-Timestamp enables replay prevention, Webhook-Event-Type enables consumer-side routing without payload parsing, Webhook-Source enables multi-source fan-in scenarios, Webhook-Api-Version enables schema migration, Webhook-Retry-Count enables retry-aware processing, and Webhook-Signature enables authentication.
Webhook headers are free to add and expensive to add later. A consumer that has already gone into production without deduplication logic can't retrofit Webhook-Id checking without downtime risk. A schema migration that could have been a smooth version bump becomes a coordinated cutover.
Design your headers on day one. You won't regret the 30 minutes it takes, and your on-call engineer at 2 AM will be quietly grateful.
Start building with GetHook and get a complete webhook header set out of the box →