Back to Blog
webhooksAPI designobservabilitydeveloper experienceplatform

Webhook Header Design: The Metadata Layer That Makes Payloads Debuggable

The payload gets all the attention, but the headers are what make a webhook integration actually operable. Here's how to design a header set that enables fast debugging, reliable deduplication, and clean observability — for both inbound and outbound webhooks.

M
Marcus Webb
Platform Engineer
April 13, 2026
9 min read

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:

HeaderPurposeExample value
Webhook-IdGlobally unique identifier for this delivery attemptwh_01HXYZ3ABCDEF
Webhook-TimestampUnix timestamp of original event creation1744536000
Webhook-SignatureHMAC-SHA256 signature for payload authenticationt=1744536000,v1=abc123...
Content-TypeAlways application/json for structured payloadsapplication/json
Webhook-Event-TypeMachine-readable event typepayment.completed
Webhook-Retry-CountNumber of times this event has been attempted0, 1, 2
Webhook-SourceIdentifier for the origin systempayments-service
Webhook-Api-VersionSchema version of the payload2026-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:

go
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.

sql
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:

go
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.
go
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:

go
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:

ProviderNotable non-standard headers
StripeStripe-Signature, Idempotency-Key
GitHubX-GitHub-Event, X-GitHub-Delivery, X-GitHub-Hook-ID
ShopifyX-Shopify-Topic, X-Shopify-Shop-Domain, X-Shopify-Webhook-Id
TwilioX-Twilio-Signature, I-Twilio-Idempotency-Token
PagerDutyX-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.

go
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 concernPractical limit
Webhook-Signature header~200 bytes (Stripe-format)
Provider JWT auth headers1–4 KB depending on claims
Total safe header budgetUnder 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:

go
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 →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.