net/go.book
All Parts Marketing

APIs for Webhooks and Event-Driven Design

Most of this book so far has been about APIs where the client asks and the server answers. A webhook flips that around: the server calls the client, unprompted, when something happens. Stripe tells your server a payment succeeded; GitHub tells your server a pull request was opened; your own system might tell a partner's server that an order shipped. Webhooks are how services notify each other about events instead of making everyone poll for changes, and they come with a specific set of design problems: how does the receiver know the request really came from you, what happens when the receiver is briefly down, and what happens if the same event arrives twice.

Sending a webhook: signing the payload

If your API sends webhooks to third parties, the receiving server needs a way to verify the request actually came from you and wasn't forged or tampered with in transit. The standard approach is an HMAC signature over the raw request body, sent in a header, computed with a secret both sides share:

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

func sendWebhook(url, secret string, event any) error {
	body, err := json.Marshal(event)
	if err != nil {
		return err
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	signature := hex.EncodeToString(mac.Sum(nil))

	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Webhook-Signature", "sha256="+signature)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		return fmt.Errorf("webhook rejected with status %d", resp.StatusCode)
	}
	return nil
}

The signature covers the exact bytes sent, which is why it's computed from the marshaled JSON, not from the original struct — if the receiver re-serializes the payload differently before checking the signature, the check will fail even for a legitimate request.

Receiving a webhook: verifying before trusting

On the receiving side, the same HMAC computation runs against the raw body, and the comparison must use a constant-time function to avoid leaking timing information about how much of the signature matched:

func webhookHandler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}

		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(body)
		expected := hex.EncodeToString(mac.Sum(nil))
		sigHeader := r.Header.Get("X-Webhook-Signature")
		received := strings.TrimPrefix(sigHeader, "sha256=")

		if !hmac.Equal([]byte(expected), []byte(received)) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}

		var event OrderShippedEvent
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "malformed event", http.StatusBadRequest)
			return
		}

		if alreadyProcessed(event.EventID) {
			w.WriteHeader(http.StatusOK) // acknowledge, don't reprocess
			return
		}

		handleOrderShipped(event)
		markProcessed(event.EventID)
		w.WriteHeader(http.StatusOK)
	}
}

hmac.Equal is doing more work than a plain == comparison would — it compares byte slices in constant time regardless of where they first differ, which prevents an attacker from timing repeated guesses to reconstruct a valid signature byte by byte.

Idempotency: the same event can arrive more than once

Networks fail in the middle of things. If the sender doesn't get a 200 OK in time — because of a timeout, not necessarily because processing actually failed — it will typically retry the same event. That means your receiver must treat delivery as "at least once," never "exactly once," and handle duplicates gracefully. The alreadyProcessed / markProcessed pair above is the minimum viable version: every event carries a unique EventID, and the receiver records which IDs it has already handled (in Redis, or a dedicated table with a unique constraint) before doing anything with side effects, such as charging a card or shipping an order twice.

A webhook receiver that isn't idempotent isn't really a webhook receiver — it's a bug waiting for the sender to retry.

Retrying on the sender side

Symmetrically, the sender needs a retry policy for when the receiver is down or slow, with exponential backoff so a struggling receiver isn't hammered further:

func sendWithRetry(url, secret string, event any) error {
	backoff := 1 * time.Second
	for attempt := 0; attempt < 5; attempt++ {
		err := sendWebhook(url, secret, event)
		if err == nil {
			return nil
		}
		time.Sleep(backoff)
		backoff *= 2
	}
	return fmt.Errorf("webhook delivery failed after retries")
}

Most production webhook senders queue this retry logic through the background job system from an earlier chapter rather than blocking the original request — the event that triggers the webhook (an order shipping) shouldn't be delayed by a slow or unreachable receiver.

Event-driven design beyond webhooks

Webhooks are the simplest form of event-driven architecture: one HTTP call per event, no broker in between. For higher volume or when you need ordering guarantees and replay across many consumers, teams typically move to a message broker (Kafka, NATS, or a cloud pub/sub service) instead of point-to-point HTTP calls. The design principles carry over unchanged, though: every message needs a stable identifier for deduplication, consumers must be idempotent, and producers need a retry strategy for when a consumer is temporarily unavailable. Webhooks are event-driven design's smallest, most approachable form — understanding them well is what makes the broker-based version make sense later.

Versioning webhook payloads

Webhook consumers, unlike your own frontend, are code you don't control and often can't force to redeploy on your schedule. Adding a field to an event payload is safe for well-behaved consumers, but removing or renaming one silently breaks anyone still relying on it. Include an explicit event schema version in every payload, and treat changing that version as seriously as any other public API versioning decision:

type OrderShippedEvent struct {
	SchemaVersion int       `json:"schema_version"`
	EventID       string    `json:"event_id"`
	OrderID       string    `json:"order_id"`
	ShippedAt     time.Time `json:"shipped_at"`
}

Frequently Asked Questions

Why sign the marshaled JSON bytes instead of computing the HMAC over the original Go struct? The receiver only ever sees the raw bytes that traveled over the network, not your in-memory struct, so the signature has to be computed over exactly what was sent. If the receiver re-serializes the payload differently before checking, or if the sender signed the struct rather than the bytes, the signatures simply won't line up even for a completely legitimate request.

Why does the receiver use hmac.Equal instead of a plain == comparison on the signatures? A normal string or byte-slice comparison in Go returns as soon as it finds the first differing byte, and that tiny timing difference is enough for an attacker to reconstruct a valid signature one byte at a time across many requests. hmac.Equal compares in constant time regardless of where the values differ, which closes off that timing attack entirely.

My receiver got the exact same event twice — is that a sign something is broken? Not necessarily — webhook delivery is "at least once," not "exactly once," because a sender that doesn't get a 200 OK in time will retry even if the first attempt actually succeeded. This is exactly why the chapter's alreadyProcessed / markProcessed pattern exists: every event carries a unique EventID so a receiver can safely ignore a duplicate instead of charging a card or shipping an order twice.

If webhooks already solve event notification, why would a team move to Kafka or NATS instead? Webhooks are point-to-point HTTP calls, which works well until you need ordering guarantees, replay across many independent consumers, or much higher volume than one HTTP call per event can comfortably handle. The underlying design principles carry over unchanged, though — stable IDs for deduplication, idempotent consumers, retry strategies for unavailable consumers — which is why understanding webhooks well is what makes the broker-based version make sense later.