Skip to content
gohacks.dev
Web

Build a Webhook Receiver in Go With Signature Checks

Build a webhook receiver in Go that checks an HMAC signature on the raw body, rejects stale and replayed events, answers fast and processes in the background.

15 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A webhook is an HTTP request that someone else's server makes to yours when something happens: a payment succeeded, a commit was pushed, a form was submitted. The endpoint is public by necessity, so the first question is not what to do with the payload but how to know it is real. Anyone who finds the URL can post a convincing "payment succeeded" event. The second question is what to do when the same event arrives three times, because that is how delivery works. Get those two right, plus the habit of answering quickly, and the receiver is sound.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This project builds one that verifies a signature, checks the timestamp, ignores duplicates and processes in the background, and it tests each path with real requests. It belongs to the web cluster that ends in a production HTTP server, uses the body limits from limiting request body size, and its mirror image, the sending side, is sending webhooks with retries. The design of the contract itself, for people who publish webhooks, is in webhook design. Everything ran on Go 1.25.6 with the standard library only.

The scheme

The sender and receiver share a secret. For each delivery, the sender builds a string from the current Unix timestamp, a dot and the exact request body, computes an HMAC-SHA256 of it with the secret, and sends the result in a header, together with the timestamp (the value below is illustrative):

Text
X-Signature: t=1767268800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The receiver recomputes the HMAC from the header's timestamp and the body it received, compares the two values, and checks that the timestamp is recent. Stripe, GitHub and most other providers use variations of this scheme. GitHub signs the body alone (X-Hub-Signature-256: sha256=...) and Stripe signs timestamp.body as here; the GitHub documentation on validating deliveries is a good reference, and HMAC itself is specified in RFC 2104. If you are debugging a provider's signature by hand, the webhook signature verifier tool computes it in the browser.

Each ingredient answers a specific attack:

  • HMAC with a shared secret proves that whoever sent the body knows the secret. Without it, anyone could post events.
  • The raw body is what is signed. Verifying parsed and re-encoded JSON would fail on harmless differences in whitespace or key order, so read the bytes first, verify, and only then parse.
  • The timestamp inside the signed data stops replay. Without it, an attacker who captures one valid request can send it again next month. With it, a captured request is useful only for the few minutes the tolerance allows.
  • Constant-time comparison denies the attacker a timing signal. A byte-by-byte == returns faster the earlier the first mismatch is, and over many requests that leaks the correct signature one byte at a time. hmac.Equal takes the same time regardless.
  • Event IDs deal with the other kind of replay: the sender's own retries, which carry valid signatures and fresh timestamps.

The receiver

receiver.goGo
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"
)

const (
	maxBody   = 1 << 20
	tolerance = 5 * time.Minute
)

type Event struct {
	ID   string          `json:"id"`
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

// Sign returns the header value a sender would put on body at time t.
func Sign(secret string, t time.Time, body []byte) string {
	ts := strconv.FormatInt(t.Unix(), 10)
	return "t=" + ts + ",v1=" + mac(secret, ts, body)
}

func mac(secret, ts string, body []byte) string {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(ts))
	h.Write([]byte("."))
	h.Write(body)
	return hex.EncodeToString(h.Sum(nil))
}

var (
	errMissing   = errors.New("missing or malformed signature header")
	errSignature = errors.New("signature does not match")
	errStale     = errors.New("timestamp outside the allowed window")
)

// Verify checks the header against the body. Several v1 values are allowed, and any one of the secrets may match,
// which is how a secret is rotated without dropping events.
func Verify(secrets []string, header string, body []byte, now time.Time) error {
	var ts string
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
		switch {
		case ok && k == "t":
			ts = v
		case ok && k == "v1":
			sigs = append(sigs, v)
		}
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || len(sigs) == 0 {
		return errMissing
	}
	matched := false
	for _, secret := range secrets {
		want := mac(secret, ts, body)
		for _, got := range sigs {
			if hmac.Equal([]byte(got), []byte(want)) { // constant time
				matched = true
			}
		}
	}
	if !matched {
		return errSignature
	}
	if age := now.Sub(time.Unix(unix, 0)); age > tolerance || age < -tolerance {
		return errStale
	}
	return nil
}

// Seen remembers event IDs for a while so a retried delivery is processed once.
type Seen struct {
	mu  sync.Mutex
	ids map[string]time.Time
	ttl time.Duration
}

func NewSeen(ttl time.Duration) *Seen { return &Seen{ids: map[string]time.Time{}, ttl: ttl} }

// First reports whether id is new, and records it.
func (s *Seen) First(id string, now time.Time) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	for k, at := range s.ids { // a real store expires entries on a schedule; this keeps the example small
		if now.Sub(at) > s.ttl {
			delete(s.ids, k)
		}
	}
	if _, dup := s.ids[id]; dup {
		return false
	}
	s.ids[id] = now
	return true
}

type Receiver struct {
	Secrets []string
	Seen    *Seen
	Queue   chan Event
	Now     func() time.Time
}

func (rc *Receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", "POST")
		http.Error(w, "POST only", http.StatusMethodNotAllowed)
		return
	}
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
	if err != nil {
		http.Error(w, "body too large or unreadable", http.StatusRequestEntityTooLarge)
		return
	}
	// Verify before parsing anything: until the signature checks out, the body is just bytes from a stranger.
	if err := Verify(rc.Secrets, r.Header.Get("X-Signature"), body, rc.Now()); err != nil {
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}
	var ev Event
	if err := json.Unmarshal(body, &ev); err != nil || ev.ID == "" || ev.Type == "" {
		http.Error(w, "event must have an id and a type", http.StatusBadRequest)
		return
	}
	if !rc.Seen.First(ev.ID, rc.Now()) {
		w.WriteHeader(http.StatusOK) // a duplicate is a success from the sender's point of view
		return
	}
	select {
	case rc.Queue <- ev:
		w.WriteHeader(http.StatusAccepted)
	default:
		// The queue is full: ask the sender to try again later instead of dropping the event.
		rc.Seen.mu.Lock()
		delete(rc.Seen.ids, ev.ID)
		rc.Seen.mu.Unlock()
		w.Header().Set("Retry-After", "30")
		http.Error(w, "busy", http.StatusServiceUnavailable)
	}
}

Read Verify first, because it holds the security. It accepts several v1 values and several candidate secrets, which is how rotation works: when you change the secret, you accept both the old and the new for a while, the sender switches, and you drop the old one. It computes an HMAC for each secret and compares each candidate in constant time. It checks the timestamp last, but it checks it, and in both directions: a timestamp far in the future is as suspicious as one far in the past, and usually means a clock problem on one side.

In ServeHTTP the order is the point. The body is read with a size cap, verified, and only then parsed. Everything before Verify treats the input as hostile bytes, and the JSON decoder never sees an unauthenticated payload. The 401 response names the reason (signature does not match, timestamp outside the allowed window) which helps a developer integrating the webhook; some teams prefer a bare 401 so that an attacker learns nothing, and either is defensible as long as the reason never includes the expected signature.

A duplicate event returns 200. That is deliberate: from the sender's side, the event has been delivered, and an error would only make it retry again. The final branch handles overload honestly. If the queue is full, the receiver forgets the event ID it just recorded and answers 503 with Retry-After, so the sender's retry is processed as new; recording the ID first and then dropping the event would turn a transient overload into permanent loss.

Trying every path

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"time"
)

func main() {
	now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
	rc := &Receiver{
		Secrets: []string{"whsec_new", "whsec_old"}, // mid-rotation: both are accepted
		Seen:    NewSeen(24 * time.Hour),
		Queue:   make(chan Event, 8),
		Now:     func() time.Time { return now },
	}
	srv := httptest.NewServer(rc)
	defer srv.Close()

	// The background worker: the handler only enqueues.
	var processed []string
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for ev := range rc.Queue {
			processed = append(processed, ev.ID+":"+ev.Type)
		}
	}()

	body := []byte(`{"id":"evt_1","type":"payment.succeeded","data":{"amount":4200}}`)
	send := func(label string, body []byte, header string, method string) {
		req, _ := http.NewRequest(method, srv.URL, strings.NewReader(string(body)))
		if header != "" {
			req.Header.Set("X-Signature", header)
		}
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Printf("%-34s error: %v\n", label, err)
			return
		}
		msg, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("%-34s %d %s\n", label, res.StatusCode, strings.TrimSpace(string(msg)))
	}

	send("valid, signed with the new secret", body, Sign("whsec_new", now, body), "POST")
	send("same event again (a retry)", body, Sign("whsec_new", now.Add(time.Second), body), "POST")
	send("valid, signed with the old secret", []byte(`{"id":"evt_2","type":"refund.created"}`),
		Sign("whsec_old", now, []byte(`{"id":"evt_2","type":"refund.created"}`)), "POST")
	send("wrong secret", body, Sign("whsec_guess", now, body), "POST")
	tampered := []byte(`{"id":"evt_9","type":"payment.succeeded","data":{"amount":999999}}`)
	send("body changed after signing", tampered, Sign("whsec_new", now, body), "POST")
	fresh := []byte(`{"id":"evt_3","type":"invoice.paid"}`)
	send("signed ten minutes ago", fresh, Sign("whsec_new", now.Add(-10*time.Minute), fresh), "POST")
	send("signed in the future", fresh, Sign("whsec_new", now.Add(10*time.Minute), fresh), "POST")
	send("no signature header", fresh, "", "POST")
	send("garbage signature header", fresh, "hello", "POST")
	send("GET instead of POST", nil, "", "GET")
	send("valid signature, not an event", []byte(`{"hello":"world"}`), Sign("whsec_new", now, []byte(`{"hello":"world"}`)), "POST")
	big := []byte(strings.Repeat("x", 2<<20))
	send("2 MB body", big, Sign("whsec_new", now, big), "POST")

	close(rc.Queue)
	wg.Wait()
	fmt.Println("processed by the worker:", processed)
}
Text
valid, signed with the new secret  202 
same event again (a retry)         200 
valid, signed with the old secret  202 
wrong secret                       401 signature does not match
body changed after signing         401 signature does not match
signed ten minutes ago             401 timestamp outside the allowed window
signed in the future               401 timestamp outside the allowed window
no signature header                401 missing or malformed signature header
garbage signature header           401 missing or malformed signature header
GET instead of POST                405 POST only
valid signature, not an event      400 event must have an id and a type
2 MB body                          413 body too large or unreadable
processed by the worker: [evt_1:payment.succeeded evt_2:refund.created]

Every row is a rule. The first delivery is accepted with 202, and the retried delivery of the same event, even with a newer timestamp and therefore a different signature, gets 200 and is not queued again: the worker's list at the end contains evt_1 once. The event signed with the old secret passes during rotation. A wrong secret and a body altered after signing both fail with 401 signature does not match, and a request signed ten minutes before, or ten minutes ahead, fails the timestamp check even though its signature is mathematically valid, which shows why the timestamp has to sit inside the signed data. Missing and malformed headers are 401. A GET is a 405 with Allow. A correctly signed body that is not an event is a 400, which matters because a valid signature proves who sent the request, not that the payload makes sense. And a 2 MB body is refused before verification with 413, so it never costs an HMAC computation.

Habits that keep a receiver honest

Acknowledge first, work later. The sender is waiting on this response with a timeout, often five to thirty seconds, and treats a timeout or a non-2xx as failure. If your handler does a database write, calls another API and sends an email, one slow dependency turns every delivery into a retry, which multiplies the load that caused the slowness. The handler above only verifies and enqueues, and the worker does the rest. A channel is enough for a demonstration; for events you cannot afford to lose on a crash, write the event to a durable queue or a database table in the handler (a single insert) and let a worker poll it, because an in-memory channel loses whatever it holds when the process restarts.

Design for duplicates and disorder. Delivery is at least once, and order is not guaranteed: invoice.paid can arrive before invoice.created. Make processing idempotent by keying on the event ID, and make handlers tolerant of state they have not seen yet, usually by fetching the current object from the sender's API instead of trusting the payload's version of it. The ID store in the example is in memory, which forgets on restart; use a table with a unique constraint on the event ID so the database decides who wins a race between two simultaneous deliveries.

Do not rely on IP allowlists alone. Some providers publish source address ranges, and filtering by them is a reasonable extra layer. It is not a substitute for the signature, because ranges change, shared infrastructure widens them, and a proxy in front of you may hide the real source address.

Keep secrets out of logs and out of the repository. Log the event ID and type, never the signature header or the secret. Load secrets from configuration as in the configuration article, and give each sender its own, so revoking one does not affect the others.

Choose response codes the sender understands. Most senders retry on any non-2xx and on timeouts, and some stop permanently after a long run of failures, disabling your endpoint. So use 2xx for anything you have accepted or deliberately ignored (duplicates, event types you do not handle), 401 for a failed signature, which a well-behaved sender will not retry usefully, and 5xx only when the retry could succeed: overload, a database that is down. Returning 500 for an event type you do not care about is how an integration gets switched off on a Friday evening.

Log what you reject. A steady trickle of 401 is scanners. A sudden burst from one legitimate integration after a deploy usually means a changed secret or a proxy that rewrote the body: a gateway that decompresses, re-encodes or trims whitespace invalidates every signature. The fix is to verify on the raw bytes before any layer touches them.

How the big providers differ

The scheme above is generic, and real providers each chose a variation, which is what makes copying a tutorial for one into an endpoint for another fail.

ProviderHeaderWhat is signed
GitHubX-Hub-Signature-256: sha256=<hex>The raw body only
StripeStripe-Signature: t=<unix>,v1=<hex><t>.<body>
SlackX-Slack-Signature: v0=<hex> plus X-Slack-Request-Timestampv0:<timestamp>:<body>

GitHub's scheme has no timestamp, so replay protection there rests entirely on the event ID in X-GitHub-Delivery, and on the fact that the payload is rarely harmful to process twice. Stripe's and Slack's include the time in the signed data, so a captured request expires. All three use HMAC-SHA256 and hex encoding, and all three need the raw body. When you integrate with a provider, read its current documentation for the exact header names and formats, then write the verification as one small function per provider behind a common interface, with the provider's own test vector as its test.

A known-answer test

A test that signs with your Sign and verifies with your Verify passes even if both are wrong in the same way. The way out is a known answer from outside your code. RFC 4231 publishes test vectors for HMAC-SHA256, and the first is short enough to carry in a test:

vector_test.goGo
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strings"
	"testing"
	"time"
)

func TestHMACMatchesRFC4231(t *testing.T) {
	key := []byte(strings.Repeat("\x0b", 20))
	h := hmac.New(sha256.New, key)
	h.Write([]byte("Hi There"))
	got := hex.EncodeToString(h.Sum(nil))
	want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
	if got != want {
		t.Fatalf("HMAC-SHA256 = %s, want %s", got, want)
	}
}

func TestToleranceBoundary(t *testing.T) {
	now := time.Unix(1_800_000_000, 0)
	body := []byte(`{"id":"e","type":"t"}`)
	for _, tc := range []struct {
		offset time.Duration
		ok     bool
	}{
		{0, true}, {-tolerance, true}, {-tolerance - time.Second, false},
		{tolerance, true}, {tolerance + time.Second, false},
	} {
		err := Verify([]string{"s"}, Sign("s", now.Add(tc.offset), body), body, now)
		if (err == nil) != tc.ok {
			t.Errorf("offset %v: err = %v, want ok=%v", tc.offset, err, tc.ok)
		}
	}
}
Text
=== RUN   TestHMACMatchesRFC4231
--- PASS: TestHMACMatchesRFC4231 (0.00s)
=== RUN   TestToleranceBoundary
--- PASS: TestToleranceBoundary (0.00s)
PASS
ok  	example.test/lab	1.271s

The first test pins the primitive to a published answer; the second pins the tolerance at its exact edges, one second inside and one second outside, in both directions. Boundary tests like this one are what catch a < that should have been <= after a refactor.

Storing events durably

The in-memory queue and ID set in this project are fine for a demonstration and wrong for anything you cannot afford to lose. The usual production shape moves both into the database. The handler verifies the signature and inserts one row, with the event ID as a unique key, then returns 2xx; a worker reads unprocessed rows and marks them done:

SQL
CREATE TABLE webhook_events (
    id          text PRIMARY KEY,           -- the sender's event ID: the unique key decides duplicates
    received_at timestamptz NOT NULL DEFAULT now(),
    type        text NOT NULL,
    payload     jsonb NOT NULL,
    processed_at timestamptz
);

-- in the handler: a duplicate delivery inserts nothing, and that is success
INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING;

That gives you de-duplication that survives restarts and works across several receiver instances, because the database arbitrates. The worker picks rows with SELECT ... WHERE processed_at IS NULL ORDER BY received_at FOR UPDATE SKIP LOCKED, so several workers can run without taking the same event. If an event keeps failing, move it to a dead letter state after N attempts instead of retrying forever, and expose the count on a dashboard. The sending side of this contract, which retries on its own schedule, is the subject of sending webhooks.

Local development

Providers cannot reach localhost, so developing a receiver needs a tunnel or a forwarding tool. Most providers ship a command line tool that listens for events and posts them to a local URL with valid signatures, and tunnelling services give your laptop a public address for the duration of a session. Either way, keep the development secret separate from the production one, and make the receiver refuse to start in production without a configured secret, so an empty string never silently becomes the key (an HMAC with an empty key is valid and trivially forgeable).

Testing without the real sender

Signing is a pure function, so tests need no network: build a body, call Sign with a fixed clock, and post to httptest. The table above is that test. Add one against the provider's published test vector (their documentation usually includes a secret, a payload and the expected signature) to prove your verification agrees with theirs, since a bug where both your signer and your verifier are wrong in the same way passes every self-made test. Providers also ship CLI tools that forward real test events to a local URL, which is the right final check before going live. The timing and replay logic is the part worth property testing with a fake clock, as in testing with a fake clock: vary the offset from minus ten minutes to plus ten and assert the boundary at exactly the tolerance.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.