webhooks

package
v9.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2026 License: AGPL-3.0 Imports: 39 Imported by: 0

Documentation

Overview

Package webhooks delivers outbound webhooks: signed, retried, ordered, and replayable.

Everything here is a guarantee rather than an opinion. What an event means, when it fires, and what its payload contains are the application's; that a subscriber can authenticate the payload, that a delivery is not lost when the process dies, that a dead subscriber cannot starve a healthy one, and that resource.updated cannot overtake resource.created are this package's.

The two halves

Dispatcher is the write side. Dispatch takes the caller's transaction executor, resolves who is subscribed, and writes one dispatch row per subscriber inside that transaction:

err := client.WithTransaction(ctx, func(q database.SQLQueryExecutor) error {
	if err := updateOrder(ctx, q, order); err != nil {
		return err
	}

	return dispatcher.Dispatch(ctx, q, &webhooks.Delivery{
		EventType:   "order.updated",
		OrderingKey: order.ID,
		Payload:     body,
	})
})

The deliveries commit with the state change that caused them, or not at all. There is no way to dispatch outside a transaction by accident: holding a SQLQueryExecutor from WithTransaction means you are already in one. This is the same seam outbox.Enqueue uses, for the same reason.

Worker is the delivery side. It claims due dispatches, signs and sends them, records every attempt, and schedules retries. It runs in its own process or goroutine and is started by Run, stopped by Close.

Why dispatches are rows and not queue messages

The unit of retry is one endpoint's copy of one event, and no broker can express that. A delivery that fanned out to five subscribers is not "failed" or "delivered" — four may have accepted it on the first attempt while the fifth is on its sixth retry. Retrying at the message level redelivers to the four that already succeeded; tracking state at the message level cannot represent the fifth's attempt count at all.

So per-endpoint state lives in a row: attempts, next_attempt, last_error, and a terminal dead flag, per (delivery, endpoint). Retry is a scheduled timestamp rather than a nack, which means it survives a worker restart, and "give up" is a state a row is in rather than a message that stopped being redelivered.

Signing

X-Platform-Signature: v1,t=1753900000,s=<hex(HMAC-SHA256(secret, "v1." + t + "." + body))>

Both the scheme and the timestamp are inside the signed material, and each is load-bearing.

The timestamp is what makes a captured request expire. A signature over the body alone is valid forever, so anyone who observes one request can replay it against the subscriber indefinitely. Verify rejects anything outside DefaultTolerance, and does so before computing any HMAC, so a replay flood costs the subscriber nothing.

The v1 prefix is what makes the construction replaceable. Binding the scheme into the signed bytes means a v1 signature can only ever verify as v1, so a later scheme can be introduced alongside it rather than by flag-day.

Rotation is the other half. Secret carries Current and Previous, and every delivery is signed under both while Previous is set — several s= components in one header. A subscriber rolls its key by accepting either signature for as long as it needs, and the operator clears Previous afterwards. A single secret per account, which is what this package was extracted to replace, cannot be rolled at all without breaking every subscriber for that account simultaneously; in practice that means it never gets rolled.

Verify ships here on purpose. Verification is where these schemes are actually got wrong — subscribers compare with ==, skip the timestamp check, or verify a re-serialized body rather than the received bytes. Handing out the sender and leaving the receiver to reimplement it from prose is how that keeps happening.

Ordering

Deliveries sharing an OrderingKey reach a given endpoint in dispatch order. The guarantee lives in the claim predicate: a keyed dispatch is claimable only when no earlier undelivered dispatch shares its key *and its endpoint*, so at most one is ever in flight per (endpoint, key) across the whole fleet.

The endpoint is in that tuple deliberately. Keyed on the ordering key alone, one subscriber timing out on resource-42.updated would hold back every other subscriber's copy of the same event — a dead endpoint stalling healthy ones, which is the failure per-endpoint circuit breaking exists to prevent, reintroduced in the claim predicate.

Deliveries with no ordering key are unordered and claim freely.

Delivery semantics

At-least-once, and it cannot be otherwise: the subscriber and the database have no shared commit, so a crash between a 200 response and the row update redelivers on restart. Subscribers must tolerate duplicates, and DeliveryIDHeader is the key to deduplicate on — it is stable across every attempt and every replay of one delivery.

Failures back off exponentially with full jitter via retrycfg.DelayFor, persisted as a timestamp so the schedule survives a restart. Past Backoff.MaxAttempts the dispatch is marked dead: skipped by every future claim, counted, and left for an operator to replay. Without that terminal state one permanently broken subscriber blocks its ordering key forever.

Some failures skip the budget entirely. A 4xx other than 408 or 429 means the subscriber understood and refused, and a URL that no longer passes CheckEndpointURL will not start passing; both are marked retry.Unretryable and go straight to dead rather than spending twenty-five attempts proving it.

Circuit breaking is the other direction. A short-circuited delivery is a failure but is explicitly *not* charged an attempt — an endpoint down for an hour would otherwise exhaust the budget of every delivery queued behind it, and they would all need replaying by hand once it recovered.

SSRF

An endpoint URL is attacker-supplied and this package makes authenticated requests to it, which is textbook server-side request forgery: point it at 169.254.169.254 and the worker fetches cloud credentials on the attacker's behalf. CheckEndpointURL enforces https and refuses any host resolving into loopback, link-local, private, or non-global space — at registration, where the rejection can be reported to whoever submitted it, and again at delivery, because DNS is mutable.

Redirects are refused rather than followed. Following one would deliver a signed payload to a host that was never registered and never checked, turning an open redirect on any subscriber's domain into a way to point the worker anywhere.

Persistence

Store is the seam. This package ships a SQL implementation (NewSQLStore) and the DDL it needs (webhooks/migrations), so adopting webhooks does not mean writing one — but an application with its own schema conventions can implement the interface instead of forking the package.

The five tables are rendered from one prefix rather than five names. They reference each other by foreign key and the queries join across them, so a consumer who could name them independently could also name them inconsistently, and nothing would catch it until the first dispatch.

The catalog

Subscribable event types are supplied at construction via WithCatalog, not stored. What an event means is an application opinion and this package has none.

Both Register and Dispatch reject a type outside the catalog. That matters because an event type is a string and strings are typo-prone: a subscription to "reciped.created" accepted silently produces an endpoint that never fires, and diagnosing it means noticing an absence.

Watching it

The two that matter most are webhooks_backlog_depth and webhooks_backlog_age_seconds, sampled on the reap tick. Every other instrument is a rate or a latency, and none of them separates "delivering steadily" from "delivering steadily while falling further behind" — only the age does.

The rest: webhooks_deliveries_dispatched against webhooks_deliveries_sent (the gap is the rollback rate), webhooks_deliveries_failed, webhooks_deliveries_short_circuited, and webhooks_deliveries_dead — alert on any increase in the last, since a dead dispatch is an event a subscriber will never see — plus webhooks_claim_errors, webhooks_dispatches_reaped, and the webhooks_delivery_latency_ms, webhooks_cycle_latency_ms, and webhooks_claimed_batch_size distributions.

Per-delivery measurements carry an endpoint attribute, because one worker serves every subscriber and a single broken one is invisible in the total. That attribute's cardinality grows with the endpoints table; an operator with enough subscribers to care should drop it in their collector rather than lose the distinction at the source.

Spans cover Dispatch, each claim, and each delivery. A cycle that claims nothing is not traced: a root span every poll interval is noise.

Index

Examples

Constants

View Source
const (
	// DefaultBatchSize is how many dispatches one cycle claims.
	DefaultBatchSize = 100
	// DefaultConcurrency is how many deliveries run at once within a batch.
	DefaultConcurrency = 16
	// DefaultPollInterval is how often the worker looks for work.
	DefaultPollInterval = time.Second
	// DefaultLeaseDuration is how long a claim is held before another worker
	// may reclaim the dispatch. It must comfortably exceed RequestTimeout, or
	// two workers will deliver the same payload concurrently.
	DefaultLeaseDuration = 60 * time.Second
	// DefaultRequestTimeout bounds one delivery request.
	DefaultRequestTimeout = 10 * time.Second
	// DefaultCircuitOpenRetryDelay is how long a dispatch waits after being
	// short-circuited.
	DefaultCircuitOpenRetryDelay = 30 * time.Second
	// DefaultRetention is how long delivered dispatches and their attempts are
	// kept before reaping.
	DefaultRetention = 7 * 24 * time.Hour
	// DefaultReapInterval is how often the reaper runs.
	DefaultReapInterval = 5 * time.Minute
	// DefaultReapBatchSize caps one reap, so a large backlog is removed over
	// several passes instead of one long-running DELETE.
	DefaultReapBatchSize = 1000
	// DefaultUserAgent identifies deliveries to subscribers.
	DefaultUserAgent = "platform-go-webhooks/1"
)
View Source
const (
	// SignatureHeader carries the signature(s) over the request body.
	SignatureHeader = "X-Platform-Signature"
	// TimestampHeader carries the signing timestamp, as Unix seconds. It is the
	// same value that appears inside the signature; it is exposed separately so
	// a subscriber can reject a stale request before doing any HMAC work.
	TimestampHeader = "X-Platform-Timestamp"
	// EventTypeHeader carries the delivery's event type, so a subscriber can
	// route without parsing the body.
	EventTypeHeader = "X-Platform-Event"
	// DeliveryIDHeader carries the delivery ID, which is stable across retries
	// of the same delivery and is therefore the subscriber's deduplication key.
	DeliveryIDHeader = "X-Platform-Delivery"
	// AttemptHeader carries which attempt this is, 1-indexed, so a subscriber
	// can tell a first delivery from a redelivery.
	AttemptHeader = "X-Platform-Attempt"

	// SignatureSchemeV1 is the only scheme this package mints.
	SignatureSchemeV1 = "v1"

	// DefaultTolerance is how far a signature's timestamp may sit from the
	// verifier's clock before Verify rejects it.
	//
	// Five minutes is the customary figure, and it is a compromise between two
	// real failures: too tight and ordinary clock skew between sender and
	// subscriber rejects good deliveries, too loose and a captured request stays
	// replayable for as long as the window lasts.
	DefaultTolerance = 5 * time.Minute
)
View Source
const DefaultContentType = "application/json"

DefaultContentType is the Content-Type deliveries carry when an Endpoint does not set one.

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the webhooks tables carry when none is configured, which is none — rendering webhooks_endpoints and its four siblings.

The webhooks_ segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_webhooks_endpoints, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.

View Source
const EndpointAttributeKey = endpointIDKey

EndpointAttributeKey is the attribute name this package labels per-endpoint measurements with.

It is exported so that instruments configured alongside a Worker — a circuit breaker's own counters, most obviously — can tag themselves identically. An endpoint that keeps tripping its breaker and an endpoint whose deliveries keep failing are the same endpoint, and a dashboard can only join those two series if both agree on the label.

Variables

View Source
var (
	// ErrInvalidEndpointURL indicates a URL that is unparseable, not absolute,
	// or not https.
	ErrInvalidEndpointURL = platformerrors.New("invalid webhook endpoint URL")

	// ErrDisallowedEndpointHost indicates a URL whose host resolves somewhere a
	// webhook must not reach — loopback, link-local, or private address space.
	// See CheckEndpointURL for why this is enforced at registration.
	ErrDisallowedEndpointHost = platformerrors.New("webhook endpoint host is not publicly routable")

	// ErrReservedHeader indicates an Endpoint whose static headers would
	// overwrite one this package sets.
	ErrReservedHeader = platformerrors.New("webhook endpoint sets a reserved header")

	// ErrNoEvents indicates an endpoint subscribing to nothing, which is never
	// what the registrant meant.
	ErrNoEvents = platformerrors.New("webhook endpoint subscribes to no events")
)
View Source
var (
	// ErrInvalidSignature indicates a signature header that is malformed,
	// carries no recognized scheme, or does not match the body under the given
	// secret. The cases are deliberately one error: telling a caller which of
	// them applied tells an attacker how close a forgery came.
	ErrInvalidSignature = platformerrors.New("invalid webhook signature")

	// ErrStaleSignature indicates a signature whose timestamp is outside the
	// tolerance. It is distinct from ErrInvalidSignature because it is the one
	// verification failure with a benign cause an operator can act on — clock
	// skew — and it says nothing about the secret.
	ErrStaleSignature = platformerrors.New("webhook signature timestamp outside tolerance")
)
View Source
var (
	// ErrUnknownEventType indicates an event type absent from the Catalog. It is
	// returned both when registering an endpoint that subscribes to it and when
	// dispatching it, so a typo cannot reach the wire from either direction.
	ErrUnknownEventType = platformerrors.New("unknown webhook event type")

	// ErrNoSigningSecret indicates an endpoint with no current signing secret.
	// Unsigned delivery is not an option this package offers: a subscriber that
	// cannot authenticate a payload cannot safely act on it.
	ErrNoSigningSecret = platformerrors.New("webhook endpoint has no signing secret")

	// ErrEndpointDisabled indicates a Replay targeting an endpoint that is
	// disabled. Dispatch skips disabled endpoints silently — that is what
	// disabling means — but an operator naming one explicitly is told why
	// nothing happened.
	ErrEndpointDisabled = platformerrors.New("webhook endpoint is disabled")

	// ErrDeliveryNotFound indicates a Replay naming a delivery/endpoint pair
	// that was never dispatched, or has since been reaped.
	ErrDeliveryNotFound = platformerrors.New("webhook delivery not found")

	// ErrNilStore indicates a nil Store. It wraps errors.ErrNilInputParameter,
	// so a caller may check either.
	ErrNilStore = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webhook store")

	// ErrNilExecutor indicates Dispatch was called without a query executor. It
	// wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilExecutor = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil query executor")

	// ErrNilDelivery indicates Dispatch was called with no Delivery.
	ErrNilDelivery = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webhook delivery")

	// ErrNilEndpoint indicates a nil Endpoint was passed for registration.
	ErrNilEndpoint = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webhook endpoint")
)
View Source
var ErrCircuitOpen = platformerrors.Wrap(circuitbreaking.ErrCircuitBroken, "webhook endpoint circuit is open")

ErrCircuitOpen indicates a delivery skipped because the endpoint's circuit breaker is open. It is a failure for retry purposes but is deliberately not counted against the attempt budget — see deliver.

View Source
var ErrLeaseTooShort = platformerrors.New("webhooks lease duration must exceed the request timeout")

ErrLeaseTooShort indicates a lease that does not outlast a request, which would let two workers deliver the same payload concurrently.

View Source
var ErrNilDatabaseClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil database client")

ErrNilDatabaseClient indicates a nil database.Client. It wraps errors.ErrNilInputParameter, so a caller may check either.

View Source
var ErrNonSuccessStatus = platformerrors.New("webhook endpoint returned a non-success status")

ErrNonSuccessStatus indicates a subscriber that answered with something other than 2xx.

Functions

func CheckEndpointURL

func CheckEndpointURL(ctx context.Context, rawURL string) error

CheckEndpointURL reports whether u is acceptable as a delivery target.

This is SSRF prevention, and it is worth being explicit about what it does and does not buy. A webhook endpoint is a URL supplied by a user that the server will then make authenticated requests to, which is the textbook shape of a server-side request forgery: point it at 169.254.169.254 and the delivery worker fetches cloud instance credentials on the attacker's behalf, or point it at an internal admin service and the worker reaches something the attacker cannot.

So: https only, and no host that resolves into loopback, link-local, private, or otherwise non-global address space.

The check runs at registration, where a rejection can be reported to whoever submitted the URL, and again at delivery, because registration alone is not sound: DNS is mutable, and a name that resolved publicly when it was registered can resolve to 127.0.0.1 by the time the worker dials it.

What this does not close is DNS rebinding. Resolution and connection are separate steps, and an attacker controlling the authoritative server can return a public address to this lookup and a private one to the dial moments later. Closing that needs the checked IP pinned into the dial itself — a custom DialContext that resolves once and refuses anything else — which this package does not do, because it would mean owning the transport rather than accepting the caller's. Deployments where that gap matters should supply a pinning transport via WithHTTPClient; this function raises the cost without claiming to eliminate it.

func Sign

func Sign(secret Secret, body []byte, at time.Time) (string, error)

Sign renders the SignatureHeader value for body at the given time, under every active key in secret.

The result looks like:

v1,t=1753900000,s=<hex>,s=<hex>

A second s= appears only during a rotation window, when secret.Previous is set. Emitting both is what lets a subscriber roll its key without coordinating an instant of downtime with whoever operates this sender: it accepts either signature while it switches, and the operator drops Previous once every subscriber has.

Verify accepts a header with any number of s= components, so widening this to a longer key list later is not a wire change.

Example

Rotation is why Secret is a pair. Deliveries are signed under both keys while Previous is set, so a subscriber can switch without coordinating an instant of downtime with the sender.

package main

import (
	"fmt"
	"strings"
	"time"

	"github.com/primandproper/platform-go/v9/webhooks"
)

func main() {
	rotating := webhooks.Secret{
		Current:  []byte("the new key"),
		Previous: []byte("the outgoing key"),
	}

	payload := []byte(`{"id":"order-7"}`)
	signedAt := time.Unix(1753900000, 0)

	signature, err := webhooks.Sign(rotating, payload, signedAt)
	if err != nil {
		panic(err)
	}

	// Two s= components: a subscriber that has moved to the new key and one that
	// has not both find a signature they can verify.
	fmt.Println(strings.Count(signature, ",s="))

	fmt.Println(webhooks.Verify(
		webhooks.Secret{Current: []byte("the new key")},
		payload, signature, webhooks.WithVerificationTime(signedAt),
	))
	fmt.Println(webhooks.Verify(
		webhooks.Secret{Current: []byte("the outgoing key")},
		payload, signature, webhooks.WithVerificationTime(signedAt),
	))

}
Output:
2
<nil>
<nil>

func Verify

func Verify(secret Secret, body []byte, signature string, opts ...VerifyOption) error

Verify checks a SignatureHeader value against body under secret, and is what a subscriber calls on receipt.

It ships with this package on purpose. Verification is where webhook schemes are actually got wrong: subscribers compare with ==, forget the timestamp check, or verify a re-serialized body rather than the received bytes. Handing out the sender and leaving the receiver to reimplement it from prose is how that keeps happening.

body must be the exact bytes received, read before any JSON decoding. Decoding and re-encoding changes key order and whitespace, and the signature covers bytes, not meaning.

A signature verifies if it matches under any key in secret, so a subscriber holding both an old and a new key accepts deliveries from either side of a rotation.

Example

Verify is what a subscriber calls on receipt. It is shipped with the sender because this is where webhook schemes are actually got wrong.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"

	"github.com/primandproper/platform-go/v9/webhooks"
)

func main() {
	secret := webhooks.Secret{Current: []byte("the shared signing key")}

	subscriber := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
		// The exact bytes received, read before any decoding. Decoding and
		// re-encoding changes key order and whitespace, and the signature covers
		// bytes rather than meaning.
		body, err := io.ReadAll(req.Body)
		if err != nil {
			res.WriteHeader(http.StatusBadRequest)

			return
		}

		if err = webhooks.Verify(secret, body, req.Header.Get(webhooks.SignatureHeader)); err != nil {
			res.WriteHeader(http.StatusUnauthorized)

			return
		}

		// The delivery ID is stable across every retry and replay of one
		// delivery, so it is the key to deduplicate on.
		_ = req.Header.Get(webhooks.DeliveryIDHeader)

		res.WriteHeader(http.StatusNoContent)
	}))
	defer subscriber.Close()

	payload := []byte(`{"id":"order-7"}`)

	signature, err := webhooks.Sign(secret, payload, time.Now())
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequestWithContext(context.Background(),
		http.MethodPost, subscriber.URL, strings.NewReader(string(payload)))
	if err != nil {
		panic(err)
	}

	req.Header.Set(webhooks.SignatureHeader, signature)

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer func() { _ = res.Body.Close() }()

	fmt.Println(res.StatusCode)

	// A tampered body no longer verifies.
	fmt.Println(webhooks.Verify(secret, []byte(`{"id":"order-8"}`), signature))

}
Output:
204
invalid webhook signature

Types

type Attempt

type Attempt struct {
	// AttemptedAt is when the request was issued.
	AttemptedAt time.Time `json:"attemptedAt"`
	// ID identifies the attempt.
	ID string `json:"id"`
	// DeliveryID is the delivery this attempted.
	DeliveryID string `json:"deliveryID"`
	// EndpointID is the endpoint it was sent to.
	EndpointID string `json:"endpointID"`
	// Error is the transport or status error, rendered. Empty on success. It is
	// a string because it is stored and read by a human, not re-wrapped.
	Error string `json:"error,omitempty"`
	// Duration is how long the request took.
	Duration time.Duration `json:"duration"`
	// StatusCode is the response status, or 0 if no response was received.
	StatusCode int `json:"statusCode"`
	// AttemptCount is which attempt this was, 1-indexed.
	AttemptCount int `json:"attemptCount"`
}

Attempt is one recorded HTTP attempt against one endpoint. Attempts are append-only and are the delivery log: what was tried, when, and what came back.

func (*Attempt) Succeeded

func (a *Attempt) Succeeded() bool

Succeeded reports whether the attempt was accepted by the subscriber.

type Catalog

type Catalog map[string]EventDefinition

Catalog is the set of event types an application publishes, keyed by event type. It is supplied at construction rather than stored, because what an event means is an application opinion and the library has none.

Subscribing to an event outside the catalog is rejected at registration, and dispatching one is rejected at Dispatch. Both matter: an event type is a string, strings are typo-prone, and a subscription to "reciped.created" that is accepted silently produces an endpoint that never fires and no signal explaining why.

func (Catalog) EventTypes

func (c Catalog) EventTypes() []string

EventTypes returns the catalog's event types, sorted, for rendering a subscription UI or an API response.

func (Catalog) Known

func (c Catalog) Known(eventType string) bool

Known reports whether eventType is in the catalog.

type CircuitBreakerFactory

type CircuitBreakerFactory func(endpointID string) (circuitbreaking.CircuitBreaker, error)

CircuitBreakerFactory builds the circuit breaker guarding one endpoint. It is called at most once per endpoint per worker, lazily, and the result is retained.

It is a factory rather than a fixed map because endpoints are registered at runtime — the set is not known when the worker is constructed, so partitioned.KeyedCircuitBreaker's operator-chosen key list does not fit. Cardinality is bounded by the endpoints table, which is bounded by however many subscribers an operator has accepted.

type ClaimedDispatch

type ClaimedDispatch struct {
	// Endpoint is the subscriber, resolved at claim time rather than at
	// dispatch time — so a secret rotated between the event and its delivery
	// signs with the current key, and an endpoint disabled in between is not
	// delivered to at all.
	Endpoint *Endpoint `json:"endpoint"`
	// Payload is the delivery body, verbatim as dispatched.
	Payload []byte `json:"payload"`
	// EventType is the delivery's event type.
	EventType string `json:"eventType"`

	Dispatch
}

ClaimedDispatch is a Dispatch the worker has leased, joined with everything needed to actually issue the request. It is assembled by the Store so the worker makes one round trip per batch rather than one per dispatch.

type Delivery

type Delivery struct {
	// ID identifies the delivery, and is what Replay names. Generated when empty.
	ID string `json:"id"`
	// EventType is the catalog event type. Must be in the Catalog.
	EventType string `json:"eventType"`
	// OrderingKey groups deliveries that must arrive in order — typically the
	// subject resource's ID. Deliveries sharing a key reach a given endpoint in
	// the order they were dispatched; deliveries with different keys, or with
	// none, are unordered relative to each other.
	//
	// Ordering is per endpoint as well as per key. A subscriber that is timing
	// out delays only its own queue for that key, never another subscriber's.
	OrderingKey string `json:"orderingKey,omitempty"`
	// Payload is the event body, delivered to every subscriber byte for byte as
	// supplied. It is json.RawMessage rather than any so that what is signed and
	// what is sent are the same bytes — re-marshaling between signing and
	// sending is exactly how a signature comes to cover something other than the
	// request body.
	Payload json.RawMessage `json:"payload"`
}

Delivery is one event to fan out. It is the application's unit; the per-endpoint unit it expands into is a dispatch, which callers do not construct.

type Dispatch

type Dispatch struct {
	// NextAttempt is when this dispatch next becomes claimable.
	NextAttempt time.Time `json:"nextAttempt"`
	// ID identifies the dispatch.
	ID string `json:"id"`
	// DeliveryID is the delivery being sent.
	DeliveryID string `json:"deliveryID"`
	// EndpointID is the subscriber it is being sent to.
	EndpointID string `json:"endpointID"`
	// OrderingKey is denormalized from the delivery so the claim predicate can
	// enforce ordering without joining. See buildSelectClaimable.
	OrderingKey string `json:"orderingKey,omitempty"`
	// LastError is the most recent failure, rendered.
	LastError string `json:"lastError,omitempty"`
	// Attempts is how many attempts have been made.
	Attempts int `json:"attempts"`
	// Dead marks a dispatch that exhausted its attempts. It is skipped by every
	// future claim and is what an operator replays.
	Dead bool `json:"dead"`
}

Dispatch is one endpoint's copy of one delivery: the unit the worker actually retries, backs off, and gives up on.

It exists as a distinct row from the Delivery because per-endpoint state is the whole point. A delivery that fanned out to five subscribers is not "failed" or "delivered" — four may have accepted it on the first attempt while the fifth is on its sixth retry, and a single status on the delivery cannot express that. Retrying at the delivery level would also redeliver to the four that already accepted it.

type Dispatcher

type Dispatcher interface {
	// Dispatch fans an event out to every endpoint subscribed to it, writing
	// through the caller's executor so the deliveries commit with the state
	// change that caused them.
	Dispatch(ctx context.Context, q database.SQLQueryExecutor, delivery *Delivery) error
	// Replay re-drives a specific past delivery to a specific endpoint, for
	// operator recovery.
	Replay(ctx context.Context, deliveryID, endpointID string) error
	// Register validates and stores an endpoint. Validation is not optional and
	// not separable: an unvalidated endpoint is an SSRF target.
	Register(ctx context.Context, endpoint *Endpoint) error
}

Dispatcher is the write side: it turns an application event into per-endpoint work, and re-drives that work when an operator asks.

func NewDispatcher

func NewDispatcher(store Store, opts ...DispatcherOption) (Dispatcher, error)

NewDispatcher builds a Dispatcher over the given Store.

type DispatcherOption

type DispatcherOption func(*dispatcher)

DispatcherOption configures a Dispatcher.

func WithCatalog

func WithCatalog(catalog Catalog) DispatcherOption

WithCatalog supplies the set of event types the application publishes.

Without it every event type is unknown and both Register and Dispatch reject everything, which is deliberate: a catalog-free dispatcher would accept subscriptions to typo'd event types that then never fire, and diagnosing that means noticing an absence.

func WithDispatcherClock

func WithDispatcherClock(c clock.Clock) DispatcherOption

WithDispatcherClock swaps the clock stamping deliveries.

func WithDispatcherLogger

func WithDispatcherLogger(logger logging.Logger) DispatcherOption

WithDispatcherLogger attaches a logger.

func WithDispatcherMetricsProvider

func WithDispatcherMetricsProvider(metricsProvider metrics.Provider) DispatcherOption

WithDispatcherMetricsProvider attaches a metrics provider. Pair it with the Worker's: dispatch rate against delivery rate is what says whether the worker is keeping up, and neither number answers that alone.

func WithDispatcherTracerProvider

func WithDispatcherTracerProvider(tracerProvider tracing.TracerProvider) DispatcherOption

WithDispatcherTracerProvider attaches a tracer provider, so a Dispatch shows up as a child of the span that owns the transaction.

func WithDispatcherURLChecker

func WithDispatcherURLChecker(checker URLChecker) DispatcherOption

WithDispatcherURLChecker replaces the URL policy Register enforces.

Pair it with the Worker's WithWorkerURLChecker: an endpoint accepted at registration and refused at delivery sits in the backlog until it dies, so the two halves must agree. See URLChecker for what replacing it costs.

type Endpoint

type Endpoint struct {
	// Headers are static headers added to every request to this endpoint, for
	// subscribers that need a routing token or a tenant hint. The signature,
	// timestamp, content type, and event headers this package sets are not
	// overridable from here — see reservedHeaders.
	Headers map[string]string `json:"headers,omitempty"`
	// ID identifies the endpoint. Generated at registration when empty.
	ID string `json:"id"`
	// URL is the absolute https:// URL deliveries are POSTed to.
	URL string `json:"url"`
	// ContentType is the request's Content-Type. Defaults to application/json.
	ContentType string `json:"contentType"`
	// Secret carries the signing keys. Never serialized: an endpoint travels
	// through API responses and logs, and its secret must not.
	Secret Secret `json:"-"`
	// Events are the catalog event types this endpoint subscribes to.
	Events []string `json:"events"`
	// Disabled stops delivery without deleting the endpoint or its history,
	// which is what an operator wants when a subscriber is misbehaving.
	Disabled bool `json:"disabled"`
}

Endpoint is one subscriber: where deliveries go, what they are signed with, and which events reach it.

func (*Endpoint) EnsureDefaults

func (e *Endpoint) EnsureDefaults()

EnsureDefaults fills an Endpoint's optional fields.

func (*Endpoint) Validate

func (e *Endpoint) Validate(ctx context.Context, catalog Catalog, checkURL URLChecker) error

Validate checks an Endpoint against the catalog it is being registered into and the URL policy it will be delivered under.

The catalog is an argument because an endpoint is only meaningful relative to the set of events an application publishes: a subscription to an event that does not exist is a silent no-op forever. checkURL is an argument so that registration and delivery cannot apply different policies — an endpoint accepted here and refused by the worker would sit in the backlog until it died. A nil checkURL means CheckEndpointURL.

type EventDefinition

type EventDefinition struct {
	// Description is human-facing prose explaining when the event fires.
	Description string `json:"description"`
}

EventDefinition describes one subscribable event type. It is deliberately thin: the library needs to know an event type exists in order to reject a subscription to one that does not, and needs nothing else about it.

Description is what an endpoint-management UI shows beside the checkbox, and the reason this is a struct rather than a set — a bare set would push every consumer into maintaining that text somewhere else, out of step with the events themselves.

type SQLStoreOption

type SQLStoreOption func(*sqlStore)

SQLStoreOption configures a SQL Store.

func WithTablePrefix

func WithTablePrefix(prefix string) SQLStoreOption

WithTablePrefix overrides DefaultTablePrefix. It must be a plain SQL identifier fragment: it is interpolated into the query text, not bound as a parameter, and it must match the prefix the migrations were rendered with.

type Secret

type Secret struct {
	// Current is the key new signatures are minted under. Required.
	Current []byte `json:"-"`
	// Previous is an outgoing key still emitted alongside Current during a
	// rotation window. Empty outside one.
	Previous []byte `json:"-"`
}

Secret carries an endpoint's HMAC signing keys.

It is a pair rather than a single value so that rotation is not an outage. Every delivery is signed under Current and, while Previous is set, again under Previous; both signatures travel in the same header. A subscriber therefore accepts deliveries throughout the window in which it is switching keys, and the operator clears Previous once every subscriber has moved.

A single per-account secret — which is what this package exists partly to replace — makes that impossible: rolling it breaks every subscriber for the account at the same instant, so in practice it never gets rolled.

type Store

type Store interface {
	// SaveEndpoint creates or replaces an endpoint and its subscriptions.
	SaveEndpoint(ctx context.Context, endpoint *Endpoint) error
	// GetEndpoint reads one endpoint, secrets included. It returns an error
	// wrapping database/sql.ErrNoRows when the endpoint does not exist.
	GetEndpoint(ctx context.Context, endpointID string) (*Endpoint, error)
	// ListEndpoints pages through registered endpoints.
	ListEndpoints(ctx context.Context, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Endpoint], error)
	// ArchiveEndpoint retires an endpoint. Its delivery history is retained:
	// the attempts log outlives the endpoint, because "what did we send them"
	// is asked most often after someone has been removed.
	ArchiveEndpoint(ctx context.Context, endpointID string) error

	// EndpointsForEvent returns the enabled, unarchived endpoints subscribed to
	// eventType, using the caller's executor.
	EndpointsForEvent(ctx context.Context, q database.SQLQueryExecutor, eventType string) ([]*Endpoint, error)
	// Enqueue writes a delivery and one dispatch per endpoint, using the
	// caller's executor, so both commit with whatever else that transaction did.
	Enqueue(ctx context.Context, q database.SQLQueryExecutor, delivery *Delivery, endpointIDs []string, now time.Time) error

	// Claim leases the next batch of due dispatches, incrementing their attempt
	// counts, and returns them ready to send.
	Claim(ctx context.Context, now time.Time, limit int, leaseUntil time.Time) ([]ClaimedDispatch, error)
	// MarkDelivered retires a dispatch that was accepted.
	MarkDelivered(ctx context.Context, dispatchID string, at time.Time) error
	// RecordFailure releases the lease, schedules the retry, and sets dead once
	// the dispatch has exhausted its attempts.
	//
	// attempts is persisted as given rather than left as Claim incremented it,
	// so the caller can decline to charge an attempt for a failure the
	// subscriber never saw — an open circuit being the case that matters.
	RecordFailure(ctx context.Context, dispatchID string, attempts int, nextAttempt time.Time, lastErr string, dead bool) error
	// RecordAttempt appends to the delivery log.
	RecordAttempt(ctx context.Context, attempt *Attempt) error
	// ListAttempts pages through the attempts recorded for one delivery.
	ListAttempts(ctx context.Context, deliveryID string, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Attempt], error)

	// Requeue makes a delivery/endpoint pair claimable again, clearing its dead
	// flag and attempt count. It returns an error wrapping ErrDeliveryNotFound
	// if the pair was never dispatched or has been reaped.
	Requeue(ctx context.Context, deliveryID, endpointID string, at time.Time) error

	// Backlog reports how many dispatches are waiting and when the oldest was
	// created, for the worker's health gauges.
	Backlog(ctx context.Context) (depth int64, oldest time.Time, err error)
	// Reap deletes delivered dispatches, their deliveries, and their attempts
	// once they age past the retention window, up to limit rows.
	Reap(ctx context.Context, before time.Time, limit int) (int64, error)
}

Store is the persistence seam.

This package ships a SQL implementation (NewSQLStore) together with the DDL it needs (webhooks/migrations), so adopting webhooks does not mean writing this. The interface exists because delivery mechanics and persistence are genuinely separable, and an application with its own schema conventions — or one storing endpoints somewhere that is not a SQL database — should not have to fork the package to keep them.

Methods taking a database.SQLQueryExecutor run inside the caller's transaction and must use it rather than a handle of their own; that is what makes Dispatch atomic with the state change that caused it. The rest own their own statements.

func NewSQLStore

func NewSQLStore(client database.Client, opts ...SQLStoreOption) (Store, error)

NewSQLStore builds a Store over the given database.

The dialect comes from the client, so the two cannot disagree. The prefix must still match the one the migrations were rendered with — nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.

type URLChecker

type URLChecker func(ctx context.Context, rawURL string) error

URLChecker vets a delivery target. CheckEndpointURL is the implementation this package uses unless a caller replaces it.

It is replaceable because a minority of deployments deliver webhooks to internal services on purpose — a sidecar, another service on the same private network — and for them CheckEndpointURL's refusal is not a safety property but a wall. Replacing it means owning the SSRF question yourself: the replacement is the only thing standing between a user-supplied URL and an authenticated request from inside your network, so it should be an allowlist of hosts you operate, not a function that returns nil.

type VerifyOption

type VerifyOption func(*verifyConfig)

VerifyOption customizes Verify.

func WithTolerance

func WithTolerance(d time.Duration) VerifyOption

WithTolerance overrides DefaultTolerance — how far the signature's timestamp may sit from the verifier's clock. A non-positive duration leaves the default in place.

There is deliberately no way to disable the check. A signature with no freshness bound is replayable forever, which is the property this scheme exists to remove.

func WithVerificationTime

func WithVerificationTime(t time.Time) VerifyOption

WithVerificationTime pins the time Verify compares the signature's timestamp against, instead of the wall clock. It exists for tests and for replaying a captured request against a known instant.

type Worker

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

Worker delivers claimed dispatches. It owns a goroutine started by Run and stopped by Close.

func NewWorker

func NewWorker(ctx context.Context, cfg *WorkerConfig, store Store, opts ...WorkerOption) (*Worker, error)

NewWorker builds a Worker. It does not start it; call Run.

ctx is used to validate the config and is not retained — Run takes its own.

func (*Worker) Close

func (w *Worker) Close(ctx context.Context) error

Close stops the worker and waits for the in-flight cycle to finish. Safe to call more than once.

func (*Worker) Run

func (w *Worker) Run()

Run is the worker loop. Like outbox.Relay.Run it takes no context: tied to a server context it would stop delivering while requests were still committing dispatch rows. The owner calls Close after the server has shut down.

Run returns only after Close.

type WorkerConfig

type WorkerConfig struct {
	// UserAgent identifies deliveries to subscribers.
	UserAgent string `env:"USER_AGENT" json:"userAgent,omitempty" yaml:"userAgent,omitempty"`
	// Backoff drives the retry schedule for failed deliveries. MaxAttempts is
	// the threshold past which a dispatch is marked dead.
	Backoff retrycfg.Config `envPrefix:"BACKOFF_" json:"backoff,omitzero" yaml:"backoff,omitempty"`
	// PollInterval is how often the worker looks for work.
	PollInterval time.Duration `env:"POLL_INTERVAL" json:"pollInterval,omitempty" yaml:"pollInterval,omitempty"`
	// LeaseDuration is how long a claim is held before it can be reclaimed.
	LeaseDuration time.Duration `env:"LEASE_DURATION" json:"leaseDuration,omitempty" yaml:"leaseDuration,omitempty"`
	// RequestTimeout bounds one delivery request.
	RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" json:"requestTimeout,omitempty" yaml:"requestTimeout,omitempty"`
	// CircuitOpenRetryDelay is how long a short-circuited dispatch waits before
	// becoming claimable again. It is a flat delay rather than a backoff step,
	// because backing off exponentially against an open circuit means the first
	// delivery after recovery can wait far longer than the outage did.
	CircuitOpenRetryDelay time.Duration `env:"CIRCUIT_OPEN_RETRY_DELAY" json:"circuitOpenRetryDelay,omitempty" yaml:"circuitOpenRetryDelay,omitempty"`
	// Retention is how long delivered dispatches are kept before reaping.
	Retention time.Duration `env:"RETENTION" json:"retention,omitempty" yaml:"retention,omitempty"`
	// ReapInterval is how often the reaper runs.
	ReapInterval time.Duration `env:"REAP_INTERVAL" json:"reapInterval,omitempty" yaml:"reapInterval,omitempty"`
	// BatchSize is how many dispatches one cycle claims.
	BatchSize int `env:"BATCH_SIZE" json:"batchSize,omitempty" yaml:"batchSize,omitempty"`
	// Concurrency is how many deliveries run at once within a batch.
	Concurrency int `env:"CONCURRENCY" json:"concurrency,omitempty" yaml:"concurrency,omitempty"`
	// ReapBatchSize caps how many rows one reap deletes.
	ReapBatchSize int `env:"REAP_BATCH_SIZE" json:"reapBatchSize,omitempty" yaml:"reapBatchSize,omitempty"`
}

WorkerConfig configures a Worker.

func (*WorkerConfig) EnsureDefaults

func (cfg *WorkerConfig) EnsureDefaults()

EnsureDefaults fills unset knobs with the package defaults.

func (*WorkerConfig) ValidateWithContext

func (cfg *WorkerConfig) ValidateWithContext(ctx context.Context) error

ValidateWithContext validates a WorkerConfig.

type WorkerOption

type WorkerOption func(*Worker)

WorkerOption configures a Worker.

func WithCircuitBreakerFactory

func WithCircuitBreakerFactory(factory CircuitBreakerFactory) WorkerOption

WithCircuitBreakerFactory supplies the per-endpoint circuit breakers.

Without it every endpoint gets a noop breaker and a permanently dead subscriber is retried at full rate forever, competing with healthy endpoints for the same worker pool.

func WithHTTPClient

func WithHTTPClient(client *http.Client) WorkerOption

WithHTTPClient supplies the client every delivery goes through.

One client for the whole worker is the point. A client built per delivery — which is what this package was extracted to replace — reuses no connections, so every delivery pays a fresh TCP handshake and a fresh TLS handshake to a subscriber it just talked to.

The supplied client's redirect policy is overridden: following a redirect would deliver a signed payload to a host the operator never registered and never had checked, which turns an open redirect on a subscriber's domain into an SSRF. Its transport is left alone.

func WithWorkerClock

func WithWorkerClock(c clock.Clock) WorkerOption

WithWorkerClock swaps the clock driving the poll loop, leases, and backoff.

func WithWorkerLogger

func WithWorkerLogger(logger logging.Logger) WorkerOption

WithWorkerLogger attaches a logger. The worker reports every delivery failure and every dead dispatch through it; without one, a subscriber that has stopped accepting deliveries is visible only in metrics.

func WithWorkerMetricsProvider

func WithWorkerMetricsProvider(metricsProvider metrics.Provider) WorkerOption

WithWorkerMetricsProvider attaches a metrics provider.

func WithWorkerTracerProvider

func WithWorkerTracerProvider(tracerProvider tracing.TracerProvider) WorkerOption

WithWorkerTracerProvider attaches a tracer provider. Cycles that claim nothing are not traced — a root span every poll interval is noise.

func WithWorkerURLChecker

func WithWorkerURLChecker(checker URLChecker) WorkerOption

WithWorkerURLChecker replaces the URL policy re-checked at delivery.

Pair it with the Dispatcher's WithDispatcherURLChecker: an endpoint accepted at registration and refused here sits in the backlog until it dies, so the two halves must agree. See URLChecker for what replacing it costs.

Directories

Path Synopsis
Package webhookscfg assembles the webhook machinery from environment configuration: the Store both halves share, the Dispatcher applications write through, and the Worker that delivers.
Package webhookscfg assembles the webhook machinery from environment configuration: the Store both halves share, the Dispatcher applications write through, and the Worker that delivers.
Package migrations supplies the webhook tables' DDL, rendered for a dialect and table prefix.
Package migrations supplies the webhook tables' DDL, rendered for a dialect and table prefix.
Package webhooksmock provides moq-generated mock implementations of interfaces in the webhooks package.
Package webhooksmock provides moq-generated mock implementations of interfaces in the webhooks package.

Jump to

Keyboard shortcuts

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