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
- Variables
- func DefaultJitter(d time.Duration) time.Duration
- func NewID() string
- func NoJitter(d time.Duration) time.Duration
- func ParseSignature(header string) (t time.Time, signatures []string, err error)
- func Sign(secret []byte, t time.Time, body []byte) string
- func SignatureHeader(t time.Time, body []byte, secrets ...[]byte) string
- func Verify(header string, body []byte, secrets [][]byte, now time.Time, ...) error
- type Clock
- type Delivery
- type Dispatcher
- type Event
- type Headers
- type InProcess
- type Jitter
- type MemoryStore
- func (m *MemoryStore) ByEvent(_ context.Context, eventID string) ([]Delivery, error)
- func (m *MemoryStore) Due(_ context.Context, now time.Time, limit int) ([]Delivery, error)
- func (m *MemoryStore) Enqueue(_ context.Context, deliveries []Delivery) error
- func (m *MemoryStore) Event(_ context.Context, id string) (Event, error)
- func (m *MemoryStore) SaveEvent(_ context.Context, ev Event) error
- func (m *MemoryStore) SaveSubscription(_ context.Context, sub Subscription) error
- func (m *MemoryStore) Subscription(_ context.Context, id string) (Subscription, error)
- func (m *MemoryStore) Subscriptions(_ context.Context, clientID string) ([]Subscription, error)
- func (m *MemoryStore) Update(_ context.Context, d Delivery) error
- type Outcome
- type Schedule
- type Secret
- type Status
- type Store
- type Subscription
- type Worker
Constants ¶
const Version = "0.2.0"
Version is the library version, sent as the User-Agent when a host does not set one.
Variables ¶
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.
var DefaultHeaders = Headers{ Signature: "Webhook-Signature", Event: "Webhook-Event", Delivery: "Webhook-Delivery", }
DefaultHeaders are the header names used when a host sets none.
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.
var ErrNotFound = errors.New("webhook: not found")
ErrNotFound is returned by a Store for an unknown subscription, event or delivery.
Functions ¶
func DefaultJitter ¶
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 ¶
NoJitter returns the delay unchanged — for tests and for hosts that schedule deterministically.
func ParseSignature ¶
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 ¶
Sign returns the lowercase hex HMAC-SHA256 of `<t> "." <body>` under secret, t being Unix seconds.
func SignatureHeader ¶
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 ¶
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.
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.
type Jitter ¶
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) Enqueue ¶
func (m *MemoryStore) Enqueue(_ context.Context, deliveries []Delivery) error
Enqueue 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.
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 )
type Schedule ¶
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).
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" )
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 ¶
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.