webhook

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 20 Imported by: 0

README

go-webhook

Signed, retried HTTP notifications from your system to endpoints that other systems register — the way a payment provider or a source-code host tells an integrator that something happened. One POST per event, a signature the receiver can check, a fixed retry schedule when the receiver does not answer, and a bounded give-up so a dead endpoint never keeps a queue alive forever.

go get github.com/gmb-lib/go-webhook

See CHANGELOG.md for what each release changed, and what it means for code that already uses this library, before you bump.

The library fixes everything a receiver can observe and leaves to the host what only the host knows: the event body, where things are stored, when the worker runs. It builds on go-platform-kit for the one cross-cutting concern it shares with every other service and library of the platform — the correlation id and its header name — and on the standard library for everything else. Because the receiver-facing contract lives here and nowhere else, a host can later move delivery to another process — same package, another host — and no receiver sees a different call.

What a receiver gets

Every delivery is one HTTP POST to the registered endpoint URL:

Header Value
Content-Type application/json
Webhook-Signature t=<unix seconds>,v1=<hex HMAC-SHA256> — two v1 values while a secret rotation is in progress
Webhook-Event the event type, so you can route before parsing the body
Webhook-Delivery the delivery id — one per event per endpoint, the same for every attempt of one delivery
X-Correlation-ID the correlation id of the act that caused the event (a request a person or another system made), when the host knew one — the same on every attempt; quote it when you ask the host about a delivery. Absent for an event no request caused
User-Agent the host's name, or go-webhook/<version>

The body is the host's event, byte for byte; the event id inside it is stable across attempts, so de-duplicate on it. A host may rename the three Webhook-* headers; its documentation then says so. X-Correlation-ID is the platform's header and is never renamed.

The signature is the lowercase hex HMAC-SHA256, keyed with a secret the host gave you when you registered, over the bytes <t> "." <raw body>. t is bound into the signed bytes so a replayed delivery can be refused once t is outside your tolerance window.

How your endpoint's answer is read:

You answer It means The library does
any 2xx delivered nothing more
429, any 5xx, or no answer (timeout, connection refused) try again later retries after 1 min · 5 min · 30 min · 2 h · 8 h · 24 h (each ±10 % jitter), then gives up (dead-letter)
any other 4xx you refuse this delivery as such does not retry (dropped) — the same bytes would get the same answer

Delivery is at least once and unordered: a retry can arrive after a later event. Order on whatever your host puts in the body for that (a sequence number, the occurrence time), never on arrival.

Verifying a delivery
import webhook "github.com/gmb-lib/go-webhook"

func handle(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
    if err != nil { http.Error(w, "read", http.StatusBadRequest); return }

    // secrets: the current one, and the previous one while a rotation is in progress.
    if err := webhook.Verify(r.Header.Get("Webhook-Signature"), body, secrets, time.Now(), 5*time.Minute); err != nil {
        http.Error(w, "signature", http.StatusUnauthorized) // a 4xx: the sender will not retry this one
        return
    }
    // de-duplicate on the event id in the body, then act; answer 2xx only once you have durably recorded it
    w.WriteHeader(http.StatusOK)
}

Verify parses the header strictly (the one place untrusted bytes are read — it is fuzzed), refuses a t further than the tolerance from now, and compares in constant time against every secret you pass. errors.Is against ErrSignatureMalformed, ErrSignatureStale, ErrSignatureMismatch tells you which.

Hosting it

Three pieces, each a small interface or struct:

store := webhook.NewMemoryStore()                 // or your own Store over the tables in sql/
disp  := &webhook.InProcess{Store: store}        // the Dispatcher your code publishes through
worker := &webhook.Worker{                       // sends what is due, records what happened
    Store:     store,
    Headers:   webhook.Headers{Signature: "Acme-Signature", Event: "Acme-Event", Delivery: "Acme-Delivery"},
    UserAgent: "acme-api/1.4.0",
    Timeout:   10 * time.Second,                 // per attempt; zero = 10 s
    Schedule:  webhook.DefaultSchedule,          // or your own
}

// registration — once per receiver endpoint
_ = disp.Subscribe(ctx, webhook.Subscription{
    ID: "sub_01", ClientID: "acme", EndpointURL: "https://dms.example/events", Enabled: true,
    Secrets:    []webhook.Secret{{Value: currentSecret}, {Value: previousSecret, ExpiresAt: rotationEnds}},
    EventTypes: []string{"order.completed"},     // empty = every type
})

// publishing — never waits on a receiver; it only writes deliveries. CorrelationID is the
// causing request's (propagation.CorrelationID(ctx) in a handler); leave it empty for
// background work and no header is sent.
_, _ = disp.Publish(ctx, webhook.Event{ID: eventID, ClientID: "acme", Type: "order.completed", Payload: body, CorrelationID: correlationID})

// delivering — one goroutine per process, or several processes against a claiming Store
go worker.Run(ctx, 5*time.Second, 100, errs)

What the host decides: the event body and its schema; the secrets and their rotation (the library signs with every active secret it is given, and refuses to send with none); the endpoint URL policy (https, allowlists — enforce them before Subscribe); how long events and deliveries are kept; and whether the header names carry the host's own brand.

What the library decides: the header format, the signature scheme, the outcome rules, the retry schedule and jitter, the delivery states (pending · retrying · delivered · dead-letter · dropped), and that a subscription disabled after an event was queued is not sent to.

Storing it

Store is five kinds of read and write over subscriptions, events and deliveries; MemoryStore is the complete reference implementation. For a database, sql/ is the table shape — copy its migrations into yours and map them, column for column, onto the Go types (V1 the three tables, V2 the event's correlation_id). Secrets are stored as references into your secret store, never as values; your Store resolves them when the worker asks. With several workers, claim rows as you read them (FOR UPDATE SKIP LOCKED) so no delivery is sent twice.

Showing it

Store.ByEvent lists every delivery of an event with its attempt record — status, attempts, last HTTP status, next attempt — so a host can show a receiver what happened to each event on each endpoint, including the ones it never received (dead-letter) and the ones it refused (dropped).

Scope / non-goals

  • No inbound webhooks: this library sends and gives receivers Verify; it does not run a server.
  • No transport beyond HTTP POST with a JSON body; no compression, no batching.
  • No secret management: the host owns generation, storage and rotation; the library only signs.
  • No ordering guarantee across events. Sequence numbers belong in the host's body.
  • No persistence of its own beyond MemoryStore; a durable Store is the host's.

Contributing

See CONTRIBUTING.md. Security reports go through SECURITY.md, never a public issue.

License

MIT — see LICENSE.

Documentation

Overview

Package webhook delivers events to HTTP endpoints that other systems register, the way a payment provider or a source-code host tells an integrator that something happened: one signed POST per event, retried on a fixed schedule when the receiver does not answer, and given up after a bounded time so a dead endpoint never keeps a queue alive forever.

The package fixes everything a receiver can observe — the headers, the signature scheme, what counts as delivered, the retry schedule, the at-least-once promise — and leaves to the host what only the host knows: the event body, where subscriptions and deliveries are stored, and when the worker runs. A host wires three things:

  • a Store, where subscriptions, events and delivery attempts live (a MemoryStore is included for tests and small deployments; sql/ carries the table shape for a relational one);
  • a Dispatcher, through which the host publishes events and registers subscriptions — InProcess is the included implementation;
  • a Worker, which sends what is due and records what happened.

Because the receiver-facing contract lives here and nowhere else, a host can move delivery to another process later — the same package, another host — and no receiver sees a different call.

Index

Constants

View Source
const Version = "0.2.0"

Version is the library version, sent as the User-Agent when a host does not set one.

Variables

View Source
var (
	ErrSignatureMalformed = errors.New("webhook: signature header malformed")
	ErrSignatureStale     = errors.New("webhook: signature timestamp outside the tolerance window")
	ErrSignatureMismatch  = errors.New("webhook: signature does not match any known secret")
)

Errors a receiver's verification returns. Compare with errors.Is.

View Source
var DefaultHeaders = Headers{
	Signature: "Webhook-Signature",
	Event:     "Webhook-Event",
	Delivery:  "Webhook-Delivery",
}

DefaultHeaders are the header names used when a host sets none.

View Source
var DefaultSchedule = Schedule{
	1 * time.Minute,
	5 * time.Minute,
	30 * time.Minute,
	2 * time.Hour,
	8 * time.Hour,
	24 * time.Hour,
}

DefaultSchedule retries six times over roughly thirty-five hours — quickly at first, for a receiver that blinked, then rarely, for one that is down for the night — and then stops. A receiver that is unreachable for that long is told the rest when it asks; it is not hammered.

View Source
var ErrNotFound = errors.New("webhook: not found")

ErrNotFound is returned by a Store for an unknown subscription, event or delivery.

Functions

func DefaultJitter

func DefaultJitter(d time.Duration) time.Duration

DefaultJitter spreads a delay uniformly within ±10 % of itself.

func NewID

func NewID() string

NewID returns a time-ordered, collision-resistant identifier: 8 bytes of Unix nanoseconds followed by 8 random bytes, hex-encoded (32 characters). Hosts with their own identifier scheme inject theirs instead.

func NoJitter

func NoJitter(d time.Duration) time.Duration

NoJitter returns the delay unchanged — for tests and for hosts that schedule deterministically.

func ParseSignature

func ParseSignature(header string) (t time.Time, signatures []string, err error)

ParseSignature splits a header value into its timestamp and its v1 signatures. It tolerates spaces around separators and ignores members it does not know (a later scheme version beside v1), but requires exactly one t and at least one v1, each well-formed. This is the one place untrusted header bytes are read, so it is deliberately strict and never panics.

func Sign

func Sign(secret []byte, t time.Time, body []byte) string

Sign returns the lowercase hex HMAC-SHA256 of `<t> "." <body>` under secret, t being Unix seconds.

func SignatureHeader

func SignatureHeader(t time.Time, body []byte, secrets ...[]byte) string

SignatureHeader builds the header value for a delivery sent at t: `t=<unix>` followed by one `v1=<hex>` per secret, in the order given (current first).

func Verify

func Verify(header string, body []byte, secrets [][]byte, now time.Time, tolerance time.Duration) error

Verify is what a receiver runs on each delivery: parse the header, refuse a timestamp further than tolerance from now (a replay, or a clock nobody trusts), then accept if any v1 matches the HMAC under any of the receiver's secrets. Comparison is constant-time. A tolerance of zero disables the window check.

Types

type Clock

type Clock func() time.Time

Clock returns the current instant. Tests and hosts with their own time source inject one; nil means time.Now.

type Delivery

type Delivery struct {
	ID             string
	EventID        string
	SubscriptionID string
	Status         Status
	// Attempts is how many times a send was tried, successful or not.
	Attempts int
	// NextAttemptAt is when the worker may try again; meaningful while Status is
	// pending or retrying.
	NextAttemptAt  time.Time
	LastHTTPStatus int
	LastError      string
	LastAttemptAt  time.Time
	CreatedAt      time.Time
	UpdatedAt      time.Time
}

Delivery is one event bound for one subscription, and the record of every attempt to get it there. One event fans out into one Delivery per matching subscription.

func (*Delivery) Attempt

func (d *Delivery) Attempt(outcome Outcome, httpStatus int, errText string, now time.Time, s Schedule, j Jitter)

Attempt records the result of one send and moves the delivery to its next state: delivered, retrying (with the next instant from the schedule), dead-letter when the schedule is exhausted, or dropped. It is the only place a delivery's status changes after creation, so the state machine is readable in one screen.

func (Delivery) Due

func (d Delivery) Due(now time.Time) bool

Due reports whether the worker should attempt this delivery at the given instant.

type Dispatcher

type Dispatcher interface {
	Publish(ctx context.Context, ev Event) ([]Delivery, error)
	Subscribe(ctx context.Context, sub Subscription) error
}

Dispatcher is the seam a host publishes through. Publish records an event and creates one delivery per matching subscription of the event's client; the deliveries are sent by a Worker, not by Publish, so publishing never waits on a receiver. Subscribe registers or replaces an endpoint.

InProcess implements it over a Store in the same process. A host that later moves delivery elsewhere replaces this one implementation with a client of that service; its own calls, and every receiver's contract, stay the same.

type Event

type Event struct {
	ID         string
	Type       string
	OccurredAt time.Time
	// ClientID names the receiver-side account the event belongs to; subscriptions
	// are matched within it, so one host can serve many receivers without one seeing
	// another's events.
	ClientID string
	Payload  json.RawMessage
	// CorrelationID is the correlation id of the act that caused the event — the request
	// a person or another system made — when that act had one. It is stored with the
	// event and sent as the X-Correlation-ID header (the platform kit's
	// propagation.HeaderCorrelationID — the one home of that header's name) on every
	// attempt of every delivery of the event, so a receiver can quote it and
	// the host can find the whole thread across its services. Empty for an event that
	// no request caused (background work): the header is then not sent.
	CorrelationID string
}

Event is one thing that happened. ID must be stable across delivery attempts: a receiver de-duplicates on it. Payload is the exact body sent — this package neither reads nor reshapes it, so the host owns the wire schema of its events.

type Headers

type Headers struct {
	// Signature carries `t=<unix seconds>,v1=<hex HMAC-SHA256>` — see [SignatureHeader].
	Signature string
	// Event carries the event type, so a receiver can route before parsing the body.
	Event string
	// Delivery carries the delivery id — one per event per endpoint, the same value on
	// every attempt of that delivery. The event id inside the body is the same across
	// endpoints too; a receiver may de-duplicate on either.
	Delivery string
}

Headers names the HTTP headers a delivery carries. A host may rename them to fit its product; the receiver-side documentation must then say so.

type InProcess

type InProcess struct {
	Store Store
	// Clock and NewID are injectable for tests and for hosts with their own schemes;
	// nil means the package defaults.
	Clock Clock
	NewID func() string
}

InProcess is the Dispatcher that writes to a Store directly.

func (*InProcess) Publish

func (p *InProcess) Publish(ctx context.Context, ev Event) ([]Delivery, error)

Publish implements Dispatcher. An event with no matching subscription is still stored (a host may add an endpoint later and ask what it missed) and returns an empty delivery list, not an error. An event without an ID gets one.

func (*InProcess) Subscribe

func (p *InProcess) Subscribe(ctx context.Context, sub Subscription) error

Subscribe implements Dispatcher.

type Jitter

type Jitter func(time.Duration) time.Duration

Jitter perturbs a delay so many deliveries that failed together do not retry together. Nil means DefaultJitter.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is a Store in process memory: complete, concurrency-safe, and forgotten on restart. For tests, and for hosts whose deliveries may be lost with the process.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore returns an empty MemoryStore.

func (*MemoryStore) ByEvent

func (m *MemoryStore) ByEvent(_ context.Context, eventID string) ([]Delivery, error)

ByEvent implements Store.

func (*MemoryStore) Due

func (m *MemoryStore) Due(_ context.Context, now time.Time, limit int) ([]Delivery, error)

Due implements Store.

func (*MemoryStore) Enqueue

func (m *MemoryStore) Enqueue(_ context.Context, deliveries []Delivery) error

Enqueue implements Store.

func (*MemoryStore) Event

func (m *MemoryStore) Event(_ context.Context, id string) (Event, error)

Event implements Store.

func (*MemoryStore) SaveEvent

func (m *MemoryStore) SaveEvent(_ context.Context, ev Event) error

SaveEvent implements Store.

func (*MemoryStore) SaveSubscription

func (m *MemoryStore) SaveSubscription(_ context.Context, sub Subscription) error

SaveSubscription implements Store.

func (*MemoryStore) Subscription

func (m *MemoryStore) Subscription(_ context.Context, id string) (Subscription, error)

Subscription implements Store.

func (*MemoryStore) Subscriptions

func (m *MemoryStore) Subscriptions(_ context.Context, clientID string) ([]Subscription, error)

Subscriptions implements Store.

func (*MemoryStore) Update

func (m *MemoryStore) Update(_ context.Context, d Delivery) error

Update implements Store.

type Outcome

type Outcome int

Outcome is what one delivery attempt established.

const (
	// OutcomeDelivered — the receiver answered 2xx: done.
	OutcomeDelivered Outcome = iota
	// OutcomeRetry — the receiver was unreachable, timed out, asked for a pause (429) or
	// failed on its side (5xx): try again on the schedule.
	OutcomeRetry
	// OutcomeDrop — the receiver refused the delivery as such (any other 4xx): retrying
	// the same bytes would get the same answer, so it is not tried again.
	OutcomeDrop
)

func Classify

func Classify(status int, err error) Outcome

Classify decides an attempt's outcome from the HTTP status and the transport error. A transport error (no response at all) always means retry; a response is judged by its status alone.

func (Outcome) String

func (o Outcome) String() string

String names the outcome.

type Schedule

type Schedule []time.Duration

Schedule is the delay before each retry, indexed by how many attempts have already failed: after the first failure wait Schedule[0], after the second Schedule[1], and so on. When failures outnumber the entries the delivery is given up (dead-lettered).

func (Schedule) Delay

func (s Schedule) Delay(failedAttempts int) (time.Duration, bool)

Delay returns how long to wait after the given number of failed attempts (1-based), and false when the schedule is exhausted.

type Secret

type Secret struct {
	Value     []byte
	ExpiresAt time.Time
}

Secret is one signing key with an optional expiry (zero = does not expire).

func (Secret) Active

func (s Secret) Active(now time.Time) bool

Active reports whether the secret may still sign at the given instant.

type Status

type Status string

Status is where a delivery stands. The receiver-visible vocabulary: a host that exposes delivery state to its integrators shows these words.

const (
	// StatusPending — created, not yet attempted.
	StatusPending Status = "pending"
	// StatusRetrying — attempted and failed in a retryable way; NextAttemptAt says when.
	StatusRetrying Status = "retrying"
	// StatusDelivered — the receiver answered 2xx. Terminal.
	StatusDelivered Status = "delivered"
	// StatusDeadLetter — every scheduled retry failed; given up. Terminal. Visible to
	// the host so it can show the integrator what it missed.
	StatusDeadLetter Status = "dead-letter"
	// StatusDropped — the receiver refused the delivery (a non-retryable 4xx). Terminal.
	StatusDropped Status = "dropped"
)

func (Status) Terminal

func (s Status) Terminal() bool

Terminal reports whether no further attempt will be made.

type Store

type Store interface {
	// SaveSubscription creates or replaces a subscription by ID.
	SaveSubscription(ctx context.Context, sub Subscription) error
	// Subscription returns one subscription, or ErrNotFound.
	Subscription(ctx context.Context, id string) (Subscription, error)
	// Subscriptions returns every subscription of a client, enabled or not.
	Subscriptions(ctx context.Context, clientID string) ([]Subscription, error)

	// SaveEvent stores an event so its payload can be sent later, and re-sent.
	SaveEvent(ctx context.Context, ev Event) error
	// Event returns one event, or ErrNotFound.
	Event(ctx context.Context, id string) (Event, error)

	// Enqueue stores new deliveries.
	Enqueue(ctx context.Context, deliveries []Delivery) error
	// Due returns up to limit deliveries whose next attempt is at or before now, oldest
	// first, excluding terminal ones. A relational implementation should claim them
	// (`FOR UPDATE SKIP LOCKED` or an equivalent) so two workers do not send the same
	// delivery twice.
	Due(ctx context.Context, now time.Time, limit int) ([]Delivery, error)
	// Update replaces a delivery by ID.
	Update(ctx context.Context, d Delivery) error
	// ByEvent returns every delivery of an event, so a host can show an integrator
	// what happened to it on each endpoint.
	ByEvent(ctx context.Context, eventID string) ([]Delivery, error)
}

Store is where subscriptions, events and deliveries live. A host implements it over its own database (sql/ carries the table shape); MemoryStore is the reference implementation and the one tests use. Every method must be safe for concurrent use.

type Subscription

type Subscription struct {
	ID          string
	ClientID    string
	EndpointURL string
	// Secrets are the keys the receiver verifies with, current first. During a
	// rotation two are active and every delivery is signed with both, so the receiver
	// can switch at its own pace; an expired secret is no longer used.
	Secrets    []Secret
	EventTypes []string
	Enabled    bool
}

Subscription is one registered endpoint. EventTypes empty means every type.

func (Subscription) ActiveSecrets

func (s Subscription) ActiveSecrets(now time.Time) [][]byte

ActiveSecrets returns the secrets that may sign at the given instant, current first.

func (Subscription) Matches

func (s Subscription) Matches(eventType string) bool

Matches reports whether the subscription wants events of the given type.

type Worker

type Worker struct {
	Store Store
	// Client sends the requests. Nil means an http.Client with Timeout; a host that
	// needs a proxy, custom TLS or a stricter dialer supplies its own. The Timeout below
	// still applies per attempt through the request context.
	Client *http.Client
	// Timeout bounds one attempt; a receiver that has not answered by then is retried.
	// Zero means 10 seconds.
	Timeout time.Duration
	// Schedule and Jitter shape the retries; nil means the package defaults.
	Schedule Schedule
	Jitter   Jitter
	// Headers names the headers sent; zero fields take [DefaultHeaders].
	Headers Headers
	// UserAgent identifies the sender; empty means "go-webhook/<Version>".
	UserAgent string
	// Clock is the worker's time source; nil means the wall clock.
	Clock Clock
	// MaxBodyDrain caps how much of a receiver's response body is read before the
	// connection is reused; zero means 64 KiB. The body is never interpreted.
	MaxBodyDrain int64
}

Worker sends due deliveries and records what happened to each. Run it in one goroutine per process, or in several processes against a Store that claims rows.

func (*Worker) Run

func (w *Worker) Run(ctx context.Context, interval time.Duration, limit int, errs chan<- error)

Run calls RunOnce every interval until ctx ends. Store errors are returned to the caller through the errs channel when non-nil, and never stop the loop.

func (*Worker) RunOnce

func (w *Worker) RunOnce(ctx context.Context, limit int) (int, error)

RunOnce sends up to limit due deliveries and returns how many it attempted. A failure to read or write the Store is returned; a receiver's refusal is not — that is recorded on the delivery. Zero limit means 100.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL