Webhook Design for Go APIs That Customers Can Trust
Design webhooks customers can trust in Go: an event envelope, per-endpoint API versions, filters, secret rotation, auto-disable, replay and signing.

A webhook is a promise made to someone else's server. When something happens in your system, you will call a URL a customer gave you, with a payload you designed, and they will build business logic on top of it. They will not read the source of your delivery code. They will read your documentation, copy your example, and be surprised in exactly the places your contract was vague: what is delivered twice, in what order, how old a retry can be, what happens when their server is down for a day, how to rotate a secret without losing events.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The mechanics of sending and receiving are covered in sending webhooks and building a receiver. This article is about the layer above them, the design that customers see: the event envelope, versioning, subscriptions, secrets, failure handling and replay. It builds that control plane in Go and runs it against three real HTTP receivers. Everything ran on Go 1.25.6, standard library only.
The envelope is the contract
Every event, whatever its type, arrives in the same envelope, so a customer writes one handler that routes on the type.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// Envelope is the contract every endpoint receives, whatever the event. Consumers route on Type, dedupe on ID and
// order on Sequence.
type Envelope struct {
ID string `json:"id"`
Type string `json:"type"`
APIVersion string `json:"api_version"`
CreatedAt time.Time `json:"created_at"`
Sequence int64 `json:"sequence"` // per resource, increasing: lets a consumer ignore a late older event
Data json.RawMessage `json:"data"`
}
// The internal event, in the newest shape.
type TaskEvent struct {
TaskID int64 `json:"task_id"`
Title string `json:"title"`
AssigneeI int64 `json:"assignee_id"`
Status string `json:"status"`
}
// Version 2024-01 had a single "owner" string and no status. An endpoint pinned to it keeps getting that shape.
type taskEventV2024 struct {
TaskID int64 `json:"task_id"`
Title string `json:"title"`
Owner string `json:"owner"`
}
var eventTypes = map[string]bool{"task.created": true, "task.updated": true, "task.deleted": true}
var Versions = []string{"2024-01", "2025-06"} // oldest first; the last is the default for new endpoints
func newID() string {
b := make([]byte, 8)
rand.Read(b)
return "evt_" + hex.EncodeToString(b)
}
// Render turns an internal event into the shape for one API version. Old versions are frozen: a golden test holds them.
func Render(version string, ev TaskEvent) (json.RawMessage, error) {
switch version {
case "2025-06":
return json.Marshal(ev)
case "2024-01":
return json.Marshal(taskEventV2024{ev.TaskID, ev.Title, fmt.Sprintf("user-%d", ev.AssigneeI)})
}
return nil, fmt.Errorf("unknown api version %q", version)
}idis unique per event and the key for deduplication. Delivery is at least once, so a receiver will see some events twice; it records ids it has processed. The id stays the same on retries and replays.typenames what happened asresource.verbin the past tense:task.created,task.updated. Name events after facts, not instructions. Keep the set small and add types rather than overloading one.api_versionsays which shapedatais in; more below.created_atis when the event happened, not when it was delivered.sequenceincreases per resource. Deliveries are not ordered, because retries reorder them: event 3 can arrive before event 2 if 2 failed once. A receiver that sees sequence 2 for a task it has already updated to sequence 3 ignores it. Without this field customers cannot tell a late event from a new one.datais the payload for this type and version.
Say it plainly in the documentation: at-least-once, no ordering guarantee, deduplicate on id, order on sequence. Customers who know this build correct receivers. Customers who find out by incident do not.
Versioning per endpoint
Payloads change. If every change is a break for everyone, customers dread upgrades and you hesitate to improve. The approach used by payment platforms is to pin each endpoint to an API version at the time it is created, and render events in that shape until the customer chooses to move.
Render does this in the code above. The internal event has the newest shape: an assignee id and a status. Version 2024-01 had a single owner string and no status. An endpoint pinned to it is rendered by a function that produces that old shape from the new data. The two shapes are separate Go types, so an edit to one cannot change the other, and a golden test pins the old output byte for byte. This is the same discipline as in API versioning, applied to events; the cost is that you carry the old renderers for as long as endpoints are pinned to them, and the benefit is that you never have to break anyone.
New fields are additions and need no version. Removing or renaming a field, or changing a type or meaning, is a new version.
Subscriptions
A subscription is a URL, a set of event types, an API version, secrets and a status.
package main
import (
"errors"
"fmt"
"net"
"net/url"
"path"
"slices"
"strings"
"sync"
"time"
)
type Secret struct {
Value string
Expires time.Time // zero means current; a retired secret keeps signing until this time
}
type Subscription struct {
ID string
URL string
EventTypes []string // exact names or a prefix pattern such as "task.*"
APIVersion string
Secrets []Secret
Status string // "active" or "disabled"
Failures int // consecutive failed deliveries
DisabledWhy string
}
var ErrBadEndpoint = errors.New("invalid endpoint")
// ValidateURL rejects endpoints that should never receive a delivery. It is the cheap first check; the delivery
// client must also refuse private addresses at dial time, as in the link preview article.
func ValidateURL(raw string, allowHTTP bool) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("%w: not a URL", ErrBadEndpoint)
}
if u.Scheme != "https" && !(allowHTTP && u.Scheme == "http") {
return fmt.Errorf("%w: must be https", ErrBadEndpoint)
}
if u.User != nil {
return fmt.Errorf("%w: credentials in the URL are not allowed", ErrBadEndpoint)
}
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) && !allowHTTP {
return fmt.Errorf("%w: private address", ErrBadEndpoint)
}
if u.Fragment != "" {
return fmt.Errorf("%w: no fragment", ErrBadEndpoint)
}
return nil
}
// Matches reports whether a subscription wants an event type.
func (s *Subscription) Matches(eventType string) bool {
return slices.ContainsFunc(s.EventTypes, func(p string) bool {
ok, _ := path.Match(strings.ReplaceAll(p, ".", "/"), strings.ReplaceAll(eventType, ".", "/"))
return ok
})
}
// ActiveSecrets are the secrets to sign with now: the current one and any retired one still inside its overlap.
func (s *Subscription) ActiveSecrets(now time.Time) []string {
var out []string
for _, sec := range s.Secrets {
if sec.Expires.IsZero() || now.Before(sec.Expires) {
out = append(out, sec.Value)
}
}
return out
}
type Registry struct {
mu sync.Mutex
subs map[string]*Subscription
next int
Allow bool // allow http and loopback, for the lab only
}
func NewRegistry() *Registry { return &Registry{subs: map[string]*Subscription{}} }
func (r *Registry) Add(rawURL string, types []string, version, secret string) (*Subscription, error) {
if err := ValidateURL(rawURL, r.Allow); err != nil {
return nil, err
}
if !slices.Contains(Versions, version) {
return nil, fmt.Errorf("%w: unknown api version", ErrBadEndpoint)
}
for _, t := range types {
if !strings.HasSuffix(t, ".*") && !eventTypes[t] {
return nil, fmt.Errorf("%w: unknown event type %q", ErrBadEndpoint, t)
}
}
r.mu.Lock()
defer r.mu.Unlock()
r.next++
s := &Subscription{ID: fmt.Sprintf("we_%d", r.next), URL: rawURL, EventTypes: types, APIVersion: version, Secrets: []Secret{{Value: secret}}, Status: "active"}
r.subs[s.ID] = s
return s, nil
}
// RotateSecret makes newSecret current and lets the old one keep signing for the overlap, so a receiver can switch
// without a gap in which its verification fails.
func (r *Registry) RotateSecret(id, newSecret string, overlap time.Duration, now time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
s := r.subs[id]
for i := range s.Secrets {
if s.Secrets[i].Expires.IsZero() {
s.Secrets[i].Expires = now.Add(overlap)
}
}
s.Secrets = append(s.Secrets, Secret{Value: newSecret})
}
func (r *Registry) Get(id string) *Subscription {
r.mu.Lock()
defer r.mu.Unlock()
return r.subs[id]
}
func (r *Registry) For(eventType string) []*Subscription {
r.mu.Lock()
defer r.mu.Unlock()
var out []*Subscription
for _, s := range r.subs {
if s.Status == "active" && s.Matches(eventType) {
out = append(out, s)
}
}
slices.SortFunc(out, func(a, b *Subscription) int { return strings.Compare(a.ID, b.ID) })
return out
}Event filters. An endpoint subscribes to exact types or to a family such as task.*. The matching uses path.Match on the dotted names, which gives the wildcard semantics without a custom parser. Customers who only care about task.created should not pay to receive and discard everything else, and you should not pay to send it.
URL validation. ValidateURL is the first line of defence at registration: HTTPS only, no credentials in the URL, no fragment, and no literal private, loopback or link-local address (which includes the cloud metadata address, 169.254.169.254). A registration check is not enough on its own. A hostname can resolve to a private address, and can resolve differently later, so the delivery client must also refuse private addresses at dial time, which is the technique in the link preview article and SSRF. Webhook delivery is an SSRF machine by design: it makes requests to URLs chosen by customers.
Secrets with an overlap. A secret that cannot be rotated gets leaked eventually and stays in use. Rotation must not lose events, and the receiver cannot switch at the same instant the sender does. RotateSecret solves it: the new secret becomes current and the old one keeps signing for an overlap. During that window each delivery carries two signatures, and the receiver accepts whichever it can verify. When the overlap ends, only the new secret signs.
Status. An endpoint is active or disabled, with a recorded reason. That is what the next section uses.
Delivery and signing
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Sign follows the Standard Webhooks layout: HMAC-SHA256 over "id.timestamp.body", one "v1,<base64>" entry per active
// secret, separated by spaces, so a receiver accepts a delivery signed with either secret during a rotation.
func Sign(secrets []string, id string, ts time.Time, body []byte) string {
var sigs []string
for _, s := range secrets {
m := hmac.New(sha256.New, []byte(s))
fmt.Fprintf(m, "%s.%d.", id, ts.Unix())
m.Write(body)
sigs = append(sigs, "v1,"+base64.StdEncoding.EncodeToString(m.Sum(nil)))
}
return strings.Join(sigs, " ")
}
type Attempt struct {
EventID, SubID string
Status int
Err string
Took time.Duration
At time.Time
}
const disableAfter = 3
type Dispatcher struct {
Reg *Registry
Client *http.Client
Now func() time.Time
mu sync.Mutex
events map[string]Envelope
attempts []Attempt
seq map[int64]int64
}
func NewDispatcher(reg *Registry, now func() time.Time) *Dispatcher {
return &Dispatcher{Reg: reg, Client: &http.Client{Timeout: 3 * time.Second}, Now: now, events: map[string]Envelope{}, seq: map[int64]int64{}}
}
// Publish renders the event once per API version in use, then delivers it to every matching endpoint.
func (d *Dispatcher) Publish(eventType string, ev TaskEvent) string {
d.mu.Lock()
d.seq[ev.TaskID]++
seq := d.seq[ev.TaskID]
d.mu.Unlock()
id := newID()
for _, sub := range d.Reg.For(eventType) {
data, err := Render(sub.APIVersion, ev)
if err != nil {
continue
}
env := Envelope{ID: id, Type: eventType, APIVersion: sub.APIVersion, CreatedAt: d.Now(), Sequence: seq, Data: data}
d.mu.Lock()
d.events[id+"/"+sub.ID] = env
d.mu.Unlock()
d.deliver(sub, env)
}
return id
}
// Replay sends a stored event to one endpoint again, with the same id so the receiver's dedupe still works.
func (d *Dispatcher) Replay(eventID, subID string) bool {
d.mu.Lock()
env, ok := d.events[eventID+"/"+subID]
d.mu.Unlock()
sub := d.Reg.Get(subID)
if !ok || sub == nil {
return false
}
d.deliver(sub, env)
return true
}
func (d *Dispatcher) deliver(sub *Subscription, env Envelope) {
body, _ := json.Marshal(env)
now := d.Now()
req, _ := http.NewRequest("POST", sub.URL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Webhook-Id", env.ID)
req.Header.Set("Webhook-Timestamp", strconv.FormatInt(now.Unix(), 10))
req.Header.Set("Webhook-Signature", Sign(sub.ActiveSecrets(now), env.ID, now, body))
t0 := time.Now()
res, err := d.Client.Do(req)
a := Attempt{EventID: env.ID, SubID: sub.ID, Took: time.Since(t0), At: now}
ok := false
if err != nil {
a.Err = err.Error()
} else {
io.Copy(io.Discard, io.LimitReader(res.Body, 1<<16))
res.Body.Close()
a.Status = res.StatusCode
ok = res.StatusCode >= 200 && res.StatusCode < 300
}
d.mu.Lock()
d.attempts = append(d.attempts, a)
d.mu.Unlock()
d.Reg.mu.Lock()
defer d.Reg.mu.Unlock()
if ok {
sub.Failures = 0
return
}
if sub.Failures++; sub.Failures >= disableAfter && sub.Status == "active" {
sub.Status, sub.DisabledWhy = "disabled", fmt.Sprintf("%d consecutive failures", sub.Failures)
}
}
func (d *Dispatcher) Attempts() []Attempt {
d.mu.Lock()
defer d.mu.Unlock()
return append([]Attempt(nil), d.attempts...)
}The headers and signature follow the layout of the Standard Webhooks specification: Webhook-Id, Webhook-Timestamp and Webhook-Signature, where the signature is an HMAC-SHA256 over id.timestamp.body, base64 encoded and prefixed with v1,. Several signatures are space-separated, which is how two secrets coexist. Signing the id and the timestamp as well as the body is what lets a receiver reject replays of old requests, by refusing timestamps older than a few minutes. A receiver that wants the full verification routine will find it in the receiver article.
Publish renders the event once for each endpoint's version, wraps it in the envelope with the per-task sequence number, stores the envelope per endpoint and delivers it. Replay sends a stored envelope again to one endpoint under the same id, so a receiver's deduplication treats it as the event it is. deliver records every attempt, status, error and duration in a log, and updates the endpoint's consecutive-failure count.
Auto-disable. After three consecutive failures an endpoint is switched to disabled, and later events are not sent to it. This protects customers' servers from a retry storm while they are down, protects your queue from endpoints that will never answer, and gives them a visible state to fix. Notify the account owner when it happens, and let them re-enable it and replay what they missed. Three is the lab's number; real systems use a longer window, hours or days of failure, before giving up.
The delivery here is one attempt per event; the retry schedule with backoff and jitter is in the sending article, and combining both gives the full engine.
The run
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"time"
)
// receiver verifies a delivery with whichever of its secrets it holds and reports what it saw.
func receiver(secret string, failing *atomic.Bool, seen *[]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
id, ts := r.Header.Get("Webhook-Id"), r.Header.Get("Webhook-Timestamp")
m := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(m, "%s.%s.", id, ts)
m.Write(body)
want := "v1," + base64.StdEncoding.EncodeToString(m.Sum(nil))
verified := false
for _, sig := range strings.Fields(r.Header.Get("Webhook-Signature")) {
if hmac.Equal([]byte(sig), []byte(want)) {
verified = true
}
}
if !verified {
http.Error(w, "bad signature", 401)
return
}
if failing != nil && failing.Load() {
http.Error(w, "temporarily broken", 500)
return
}
*seen = append(*seen, strings.TrimSpace(string(body)))
w.WriteHeader(200)
})
}
func main() {
clock := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
now := func() time.Time { return clock }
reg := NewRegistry()
reg.Allow = true // the lab's receivers are plain http on loopback
d := NewDispatcher(reg, now)
var seenNew, seenOld, seenFlaky []string
var flaky atomic.Bool
srvNew := httptest.NewServer(receiver("whsec_new_customer", nil, &seenNew))
srvOld := httptest.NewServer(receiver("whsec_legacy_customer", nil, &seenOld))
srvFlaky := httptest.NewServer(receiver("whsec_flaky", &flaky, &seenFlaky))
defer srvNew.Close()
defer srvOld.Close()
defer srvFlaky.Close()
subNew, _ := reg.Add(srvNew.URL, []string{"task.*"}, "2025-06", "whsec_new_customer")
subOld, _ := reg.Add(srvOld.URL, []string{"task.created"}, "2024-01", "whsec_legacy_customer")
subFlaky, _ := reg.Add(srvFlaky.URL, []string{"task.created", "task.updated"}, "2025-06", "whsec_flaky")
fmt.Println("== one event, three endpoints on two API versions")
ev := TaskEvent{TaskID: 7, Title: "write the post", AssigneeI: 42, Status: "doing"}
id := d.Publish("task.created", ev)
fmt.Println("event:", id)
fmt.Println("2025-06 endpoint got:", seenNew[0])
fmt.Println("2024-01 endpoint got:", seenOld[0])
fmt.Println("\n== filters")
d.Publish("task.updated", ev)
d.Publish("task.deleted", ev)
fmt.Printf("task.* endpoint saw %d events, the task.created-only endpoint saw %d\n", len(seenNew), len(seenOld))
fmt.Println("per-task sequence on the last envelope:", strings.Contains(seenNew[len(seenNew)-1], `"sequence":3`))
fmt.Println("\n== a failing endpoint is disabled after", disableAfter, "consecutive failures")
flaky.Store(true)
for i := 0; i < 5; i++ {
d.Publish("task.updated", ev)
}
fmt.Printf("status=%s (%s); %d attempts logged: 2 successes before it broke, 3 failures; the last 2 of 5 publishes were not sent to it\n", subFlaky.Status, subFlaky.DisabledWhy, countFor(d, subFlaky.ID))
flaky.Store(false)
var failed Attempt
for _, a := range d.Attempts() {
if a.SubID == subFlaky.ID && a.Status == 500 {
failed = a // the first event it missed
break
}
}
fmt.Println("after the fix, replaying the first failed event to it:", d.Replay(failed.EventID, subFlaky.ID), "- it has now received", len(seenFlaky), "events")
fmt.Println("\n== secret rotation with a 10 minute overlap")
reg.RotateSecret(subNew.ID, "whsec_rotated", 10*time.Minute, now())
fmt.Println("signing with", len(subNew.ActiveSecrets(now())), "secrets during the overlap")
before := len(seenNew)
d.Publish("task.updated", ev)
fmt.Println("receiver still on the old secret accepted it:", len(seenNew) == before+1)
clock = clock.Add(11 * time.Minute)
fmt.Println("after the overlap, signing with", len(subNew.ActiveSecrets(now())), "secret")
d.Publish("task.updated", ev)
fmt.Println("receiver that never switched is now refused:", len(seenNew) == before+1)
_ = subOld
fmt.Println("\n== endpoint validation")
reg.Allow = false
for _, u := range []string{"http://example.com/hook", "https://user:[email protected]/hook", "https://10.0.0.5/hook", "https://127.0.0.1/hook", "https://example.com/hook#frag", "https://example.com/hook"} {
_, err := reg.Add(u, []string{"task.created"}, "2025-06", "s")
fmt.Printf("%-40s %v\n", u, err)
}
}
func countFor(d *Dispatcher, sub string) int {
n := 0
for _, a := range d.Attempts() {
if a.SubID == sub {
n++
}
}
return n
}== one event, three endpoints on two API versions
event: evt_c69fed1f2a84bc28
2025-06 endpoint got: {"id":"evt_c69fed1f2a84bc28","type":"task.created","api_version":"2025-06","created_at":"2026-06-01T12:00:00Z","sequence":1,"data":{"task_id":7,"title":"write the post","assignee_id":42,"status":"doing"}}
2024-01 endpoint got: {"id":"evt_c69fed1f2a84bc28","type":"task.created","api_version":"2024-01","created_at":"2026-06-01T12:00:00Z","sequence":1,"data":{"task_id":7,"title":"write the post","owner":"user-42"}}
== filters
task.* endpoint saw 3 events, the task.created-only endpoint saw 1
per-task sequence on the last envelope: true
== a failing endpoint is disabled after 3 consecutive failures
status=disabled (3 consecutive failures); 5 attempts logged: 2 successes before it broke, 3 failures; the last 2 of 5 publishes were not sent to it
after the fix, replaying the first failed event to it: true - it has now received 3 events
== secret rotation with a 10 minute overlap
signing with 2 secrets during the overlap
receiver still on the old secret accepted it: true
after the overlap, signing with 1 secret
receiver that never switched is now refused: true
== endpoint validation
http://example.com/hook invalid endpoint: must be https
https://user:[email protected]/hook invalid endpoint: credentials in the URL are not allowed
https://10.0.0.5/hook invalid endpoint: private address
https://127.0.0.1/hook invalid endpoint: private address
https://example.com/hook#frag invalid endpoint: no fragment
https://example.com/hook <nil>Two versions of one event. The same task.created went to a 2025-06 endpoint as assignee_id: 42 and status: "doing", and to a 2024-01 endpoint as owner: "user-42", with the same event id and sequence in both envelopes.
Filters. After a created, an updated and a deleted event, the task.* endpoint had seen three, the created-only endpoint one. The sequence on the third envelope for task 7 was 3.
Failure and replay. The third endpoint, which subscribes to created and updated, then started returning 500s. After three consecutive failures it was disabled with the reason "3 consecutive failures", and the remaining publishes were not sent to it. The log held five attempts for it, two successes and three failures. After the customer's fix, replaying the first failed event delivered it again, bringing the endpoint's received count to three.
Rotation. With a ten-minute overlap each delivery was signed twice, and a receiver still holding the old secret accepted the event. Eleven minutes later only the new secret signed, and the receiver that had not switched was refused. That is the behavior you want, and what the overlap length buys: time for customers to deploy.
Validation. The last block shows the registration check refusing a plain http URL, credentials in the URL, a private address, a loopback address and a fragment, and accepting a normal HTTPS endpoint.
Tests
package main
import (
"strings"
"testing"
"time"
)
var t0 = time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
func TestSignKnownAnswer(t *testing.T) {
// Computed independently: printf 'evt_1.1780315200.{}' | openssl dgst -sha256 -hmac whsec_test -binary | base64
const want = "v1,9Zb/6GVysVrejoaphYwKFf/WWkqihYj84D567ucsllQ="
if got := Sign([]string{"whsec_test"}, "evt_1", t0, []byte("{}")); got != want {
t.Fatalf("got %q, want %q", got, want)
}
two := Sign([]string{"whsec_test", "other"}, "evt_1", t0, []byte("{}"))
if f := strings.Fields(two); len(f) != 2 || f[0] != want {
t.Fatalf("two secrets should give two signatures, the first unchanged: %q", two)
}
}
func TestEventTypeMatching(t *testing.T) {
cases := []struct {
patterns []string
event string
want bool
}{
{[]string{"task.*"}, "task.created", true},
{[]string{"task.created"}, "task.updated", false},
{[]string{"task.created", "task.deleted"}, "task.deleted", true},
{[]string{"task.*"}, "project.created", false},
{[]string{"task.*"}, "task", false},
}
for _, c := range cases {
if got := (&Subscription{EventTypes: c.patterns}).Matches(c.event); got != c.want {
t.Errorf("%v vs %q = %v, want %v", c.patterns, c.event, got, c.want)
}
}
}
func TestVersion2024StaysFrozen(t *testing.T) {
got, _ := Render("2024-01", TaskEvent{TaskID: 7, Title: "x", AssigneeI: 42, Status: "done"})
if string(got) != `{"task_id":7,"title":"x","owner":"user-42"}` {
t.Fatalf("the 2024-01 shape changed: %s", got)
}
}
func TestEndpointValidation(t *testing.T) {
bad := []string{"http://example.com/h", "https://u:[email protected]/h", "https://10.1.2.3/h", "https://[::1]/h", "https://169.254.169.254/h", "ftp://example.com/h", "https://example.com/h#x", "not a url", ""}
for _, u := range bad {
if ValidateURL(u, false) == nil {
t.Errorf("%q was accepted", u)
}
}
if err := ValidateURL("https://hooks.example.com/in?token=abc", false); err != nil {
t.Errorf("a normal endpoint was refused: %v", err)
}
}
func TestSecretsRetireAfterTheOverlap(t *testing.T) {
reg := NewRegistry()
reg.Allow = true
s, _ := reg.Add("http://127.0.0.1:1/h", []string{"task.*"}, "2025-06", "old")
reg.RotateSecret(s.ID, "new", 5*time.Minute, t0)
if got := s.ActiveSecrets(t0.Add(time.Minute)); len(got) != 2 {
t.Fatalf("during the overlap: %v", got)
}
if got := s.ActiveSecrets(t0.Add(6 * time.Minute)); len(got) != 1 || got[0] != "new" {
t.Fatalf("after the overlap: %v", got)
}
}=== RUN TestSignKnownAnswer
--- PASS: TestSignKnownAnswer (0.00s)
=== RUN TestEventTypeMatching
--- PASS: TestEventTypeMatching (0.00s)
=== RUN TestVersion2024StaysFrozen
--- PASS: TestVersion2024StaysFrozen (0.00s)
=== RUN TestEndpointValidation
--- PASS: TestEndpointValidation (0.00s)
=== RUN TestSecretsRetireAfterTheOverlap
--- PASS: TestSecretsRetireAfterTheOverlap (0.00s)
PASS
ok example.test/lab 1.285sThe tests pin the parts customers depend on: the signature against a value computed independently with openssl (never test a signer only against itself), the wildcard matching table, the frozen 2024-01 shape, the endpoint validation list including the cloud metadata address, and the secret overlap boundary.
package main
import "testing"
func BenchmarkSignOneSecret(b *testing.B) {
body := make([]byte, 2048)
b.SetBytes(int64(len(body)))
b.ReportAllocs()
for b.Loop() {
Sign([]string{"whsec_test"}, "evt_1", t0, body)
}
}
func BenchmarkSignTwoSecrets(b *testing.B) {
body := make([]byte, 2048)
b.SetBytes(int64(len(body)))
b.ReportAllocs()
for b.Loop() {
Sign([]string{"whsec_old", "whsec_new"}, "evt_1", t0, body)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkSignOneSecret-10 822981 1443 ns/op 1419.22 MB/s 696 B/op 12 allocs/op
BenchmarkSignTwoSecrets-10 416442 2829 ns/op 724.06 MB/s 1488 B/op 25 allocs/op
PASS
ok example.test/lab 2.679sSigning a 2 KB body took 1.4 µs with one secret and 2.8 µs with two. The cost of signing is negligible next to a network call to the customer, so there is no reason to skip it or sign less than the whole body.
What the documentation must say
The code is half the product. The other half is a page that states every promise, because customers will build to what is written and to nothing else. A short checklist of what belongs there:
- Delivery semantics. At least once, no ordering guarantee, deduplicate on
id, order onsequence. - What counts as success. Any 2xx within the timeout, and what happens otherwise. State the timeout in seconds.
- The retry schedule. How many attempts, at what intervals, over what total period, and what happens after the last one. The schedule from the sending article is a reasonable starting point.
- Where requests come from. Customers behind firewalls need a list of source addresses or a way to learn them, and should be told they change.
- How to verify. The exact signed string, the tolerance for timestamps, a copy-paste snippet in Go and in the other languages your customers use, and a known test vector so they can check their implementation against yours.
- The event catalogue. Each type, a real sample payload per API version, and the conditions under which it fires. Samples drift if written by hand; generate them from the same renderers the sender uses.
- Versioning and deprecation. How versions are pinned, how to change one, and how much notice a retirement gets.
- Operations. How to rotate a secret, what auto-disable does and how to re-enable, how to replay, how long the delivery log is kept.
- Limits. Maximum payload, maximum endpoints per account, redirect behavior.
A page that covers these questions removes most of the support tickets before they exist.
Decisions that make webhooks trustworthy
Thin or fat payloads. A fat payload carries the whole resource, so the receiver needs no further call. A thin payload carries ids and a URL to fetch the current state. Fat is convenient and can be stale or large by the time it arrives, and it spreads data to every endpoint you have. Thin is smaller and always current, at the price of an authenticated API call per event. Many systems send fat payloads of small resources and thin ones of large resources. Pick, document, and keep to it.
Timeouts and limits. Wait a few seconds for a response, treat anything outside 2xx as a failure, ignore the response body beyond a small cap, and do not follow redirects blindly. Cap the payload size and tell customers the cap.
Delivery log and replay UI. Customers need to see attempts: time, status, duration, a response snippet, and a button to resend. That log is also your own best debugging tool. Keep it for a defined number of days.
A way to test. A "send test event" button and a CLI that forwards events to a local port remove the commonest barrier to adoption. Test events should be marked as such in the envelope.
Ordering by sequence, not by arrival. Say so, and offer the sequence. If your domain needs strict ordering, say what you guarantee and what it costs.
Deprecation. Retire event types and API versions with notice, a header or field warning in the payloads, and a date, as in deprecating endpoints.
Security of the secret. Show it once at creation, store it so the delivery engine can use it (you need the plaintext to sign, so encrypt it at rest with a key kept outside the database), and never put it in logs.
Webhook design is mostly writing down what you will and will not do and then doing it. The signature layout is specified at standardwebhooks.com, the HMAC primitive in crypto/hmac, and URL-based request forgery is described in the OWASP SSRF guide.


