Webhooks
> Primitives for signing, verifying, and retrying webhook deliveries.
The webhook package ships three composable primitives for outbound and
inbound webhook plumbing: a Signer that produces a Stripe-style header,
a Verifier that validates the same header in constant time, and a
RetryPolicy for exponential backoff with jitter. The package is a leaf
that depends only on the Go standard library, so you can compose it with
your own queue, transport, or HTTP client of choice.
Import path: github.com/velocitykode/velocity/webhook
Signer with httpclient for
outbound delivery and queue for durable
retries.Decision matrix
| Situation | Helper |
|---|---|
| Sign an outbound payload | NewSigner(secret).Header(payload) |
| Verify an incoming payload | NewVerifier(secret).Verify(payload, header) |
| Reject replays of a previously verified payload | Set Verifier.Nonces to a NonceStore |
| Schedule the next retry attempt | DefaultRetryPolicy.Next(attempt) |
| Plug in a non-default MAC primitive | Implement Algorithm and assign it to Signer.Algorithm / Verifier.Algorithm |
Signer
Signer produces a signature header value over a payload using a
pluggable Algorithm and a shared Secret. The default algorithm is
HMACSHA256; supply your own implementation of the Algorithm
interface to swap in a different MAC.
type Algorithm interface {
Name() string
Sign(secret, payload []byte) []byte
}Construct a signer with NewSigner(secret []byte) *Signer, which
preconfigures HMACSHA256. The struct fields are exported so tests can
override the time source via Now func() time.Time.
s := webhook.NewSigner([]byte(os.Getenv("WEBHOOK_SECRET")))
header, err := s.Header(payload)
if err != nil {
return err
}
// header == "t=1714972800,v1=4f3c...e9"
req.Header.Set("X-Signature", header)Header returns a fully-formed t=<unix>,v1=<hex> value. The format
mirrors Stripe’s webhook signature scheme: the timestamp is part of the
signed material, so a verifier can reject stale or replayed deliveries
without trusting the header alone.
If you need the raw parts:
sig, ts, err := s.Sign(payload)
// sig is hex-encoded HMAC, ts is the unix-second timestamp stringSign returns ErrNoAlgorithm if Algorithm is nil and
ErrMissingSecret if Secret is empty.
<timestamp>.<payload>, not over payload
alone. Without that framing, an attacker who captures a valid header
could swap the visible t= value for a fresh one and the MAC would
still verify. Framing binds the timestamp into the signed bytes so the
verifier rejects any tampered timestamp.Verifier
Verifier parses a header, recomputes the MAC over the same framed
payload, and compares in constant time using crypto/subtle. A
Tolerance window rejects timestamps that are too far behind or ahead
of the current clock.
v := webhook.NewVerifier([]byte(os.Getenv("WEBHOOK_SECRET")))
// Tolerance defaults to 5 minutes
if err := v.Verify(payload, r.Header.Get("X-Signature")); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}Use VerifyContext(ctx, payload, header) when the underlying
NonceStore should observe request cancellation.
Verify returns one of the following sentinel errors:
| Error | Meaning |
|---|---|
ErrMalformedHeader | Header is missing, has wrong format, or contains a non-numeric timestamp / non-hex signature. |
ErrMissingSecret | Verifier.Secret is nil or empty. |
ErrNoAlgorithm | Verifier.Algorithm is nil. |
ErrTimestampOutOfTolerance | ` |
ErrSignatureMismatch | Recomputed MAC does not match the supplied signature. |
ErrReplay | A NonceStore is configured and the nonce was already observed. |
Setting Tolerance to zero disables the timestamp check entirely.
NonceStore
When Verifier.Nonces is non-nil, the hex signature itself is used as
the nonce and a successful verification additionally records that nonce
so a second delivery of the same signed payload returns ErrReplay.
The TTL written to the store equals Verifier.Tolerance (or 5 minutes
when Tolerance is zero), so expired signatures cannot be replayed
indefinitely.
type NonceStore interface {
CheckAndMark(ctx context.Context, nonce string, ttl time.Duration) (alreadySeen bool, err error)
}CheckAndMark is a single atomic operation. A naive Seen(nonce)
followed by Mark(nonce) allows two concurrent verifications of the
same payload to both observe alreadySeen=false and both succeed; the
single-call shape eliminates that TOCTOU window by construction.
Memory driver
Import path: github.com/velocitykode/velocity/webhook/drivers/nonce
import nonceMem "github.com/velocitykode/velocity/webhook/drivers/nonce"
store := nonceMem.NewMemory(1 * time.Minute) // sweep interval
defer store.Close(context.Background())
v := webhook.NewVerifier(secret)
v.Nonces = storeThe memory driver is appropriate for development, single-process
deployments, and tests. It is backed by a sync.RWMutex-protected map
with two expiry mechanisms working together:
- Lazy expiry on read.
CheckAndMarkchecks the stored expiry before deciding whether the nonce is still live; an expired entry is treated as absent and overwritten with a fresh TTL. - Background sweep. A goroutine scoped to the sweep interval scans
the map and deletes entries whose expiry has passed. Pass a
non-positive interval to
NewMemoryto disable the sweep entirely (entries still expire on read but stale records accumulate until process restart).
The sweep loop wraps each tick in a deferred recover so a transient
panic in one iteration never silently disables nonce expiry for the
lifetime of the process. Install an observer with
store.SetOnPanic(fn func(any)) to surface those recovered panics
through your logger or metrics pipeline.
store.Len() reports the current map size (including not-yet-swept
expired entries) and is primarily useful for tests.
webhook.NonceStore for replay protection across
processes.RetryPolicy
RetryPolicy describes an exponential-backoff schedule with bounded
uniform jitter and a hard attempt cap. The schedule for attempt N
(0-indexed) is:
delay = min(BaseDelay * Factor^attempt, Cap)
delay = delay + uniform(-Jitter*delay, +Jitter*delay)type RetryPolicy struct {
BaseDelay time.Duration
Factor float64
MaxAttempts int
Jitter float64
Cap time.Duration
}DefaultRetryPolicy is a sensible starting point: 1s base, factor 2,
8 attempts max, 20% jitter, capped at 5 minutes.
p := webhook.DefaultRetryPolicy
for attempt := 0; ; attempt++ {
err := deliver(ctx, payload)
if err == nil {
return nil
}
delay, ok := p.Next(attempt)
if !ok {
// Out of attempts; route to dead-letter sink.
return err
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}Next(attempt int) (time.Duration, bool) returns (0, false) once
attempt >= MaxAttempts. Treat the second return as “stop retrying”
and route the work to a dead-letter sink of your choice.
Field semantics:
Factor <= 1disables growth: every retry happens atBaseDelay, still subject to jitter andCap.Jitteris clamped to[0, 1]. A value of0.2yields a delay uniformly distributed in[0.8*delay, 1.2*delay].Cap == 0disables the upper bound on the exponential delay.- Negative
attemptis treated as0; negativeBaseDelayis treated as0.
Jitter is sampled from math/rand/v2’s package-level generator
(ChaCha8-backed, seeded from OS entropy at package init). Jitter is
not security-critical but the source is non-predictable enough to keep
synchronized retry storms from forming across replicas.
Recipe: sign and send an outbound webhook
When: your service publishes an event to a customer-supplied URL and you want their handler to be able to authenticate the call.
import (
"bytes"
"context"
"encoding/json"
"net/http"
"time"
"github.com/velocitykode/velocity/httpclient"
"github.com/velocitykode/velocity/webhook"
)
func deliver(ctx context.Context, c *httpclient.Client, url string, secret []byte, event any) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
s := webhook.NewSigner(secret)
header, err := s.Header(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Signature", header)
p := webhook.DefaultRetryPolicy
for attempt := 0; ; attempt++ {
resp, err := c.Do(req)
if err == nil && resp.StatusCode < 500 {
resp.Body.Close()
return nil
}
if resp != nil {
resp.Body.Close()
}
delay, ok := p.Next(attempt)
if !ok {
return err
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
}Why this shape: the signer produces the header once per payload, not once per retry, so identical bytes hit the wire on every attempt and the receiver’s idempotency keys work as intended. The retry policy treats only 5xx responses (and transport errors) as retryable.
Recipe: verify an incoming webhook with replay protection
When: you receive webhooks from an upstream provider and want to reject both forged signatures and replays.
import (
"context"
"errors"
"io"
"net/http"
"time"
"github.com/velocitykode/velocity/webhook"
nonceMem "github.com/velocitykode/velocity/webhook/drivers/nonce"
)
var (
secret = []byte("...")
store = nonceMem.NewMemory(1 * time.Minute)
)
func handle(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
defer r.Body.Close()
v := webhook.NewVerifier(secret)
v.Tolerance = 5 * time.Minute
v.Nonces = store
err = v.VerifyContext(r.Context(), payload, r.Header.Get("X-Webhook-Signature"))
switch {
case errors.Is(err, webhook.ErrReplay):
// Already processed; ack idempotently rather than 401.
w.WriteHeader(http.StatusOK)
return
case err != nil:
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Process the verified payload...
w.WriteHeader(http.StatusOK)
}Why this shape: reading the raw body before parsing is essential -
the signature is over those exact bytes. The ErrReplay branch returns
200 instead of 401 because a duplicate is the upstream’s retry doing
its job, not an attack.
Related
- HTTP Client - instrumented outbound
client; pair with
Signerfor signed deliveries. - Queue System - durable background workers; enqueue webhook deliveries so retries survive restarts.
- Events - in-process pub/sub; a typical trigger for outbound webhook fan-out.