Documentation
¶
Index ¶
- Constants
- Variables
- type AuditWriter
- type CreateEndpointInput
- type CreateEndpointResult
- type DeliveriesResponse
- type DeliveryView
- type DispatchLock
- type DispatchLocker
- type Dispatcher
- type DispatcherConfig
- type EndpointDeliveryView
- type EndpointStats
- type EventBus
- type HTTPClient
- type Handler
- type OutboxDispatcher
- type OutboxHandler
- type OutboxRow
- type OutboxStore
- func (s *OutboxStore) Enqueue(ctx context.Context, tx *sql.Tx, tenantID, eventType string, ...) (string, error)
- func (s *OutboxStore) EnqueueStandalone(ctx context.Context, tenantID, eventType string, payload map[string]any) (string, error)
- func (s *OutboxStore) FailedCount(ctx context.Context) (int64, error)
- func (s *OutboxStore) PendingCount(ctx context.Context) (int64, error)
- func (s *OutboxStore) ProcessBatch(ctx context.Context, limit int, handler OutboxHandler) (int, error)
- func (s *OutboxStore) TryDispatcherLock(ctx context.Context) (DispatchLock, bool, error)
- type PostgresStore
- func (s *PostgresStore) CreateDeliveriesForEvent(ctx context.Context, tenantID, eventID string, endpointIDs []string, ...) ([]domain.WebhookDelivery, error)
- func (s *PostgresStore) CreateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
- func (s *PostgresStore) CreateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
- func (s *PostgresStore) CreateEndpointTx(ctx context.Context, tx *sql.Tx, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
- func (s *PostgresStore) CreateEvent(ctx context.Context, tenantID string, event domain.WebhookEvent) (domain.WebhookEvent, error)
- func (s *PostgresStore) CreateEventWithDeliveries(ctx context.Context, tenantID string, event domain.WebhookEvent, ...) (domain.WebhookEvent, []domain.WebhookDelivery, error)
- func (s *PostgresStore) CreateReplayEvent(ctx context.Context, tenantID, originalEventID string) (domain.WebhookEvent, error)
- func (s *PostgresStore) DeleteEndpoint(ctx context.Context, tenantID, id string) error
- func (s *PostgresStore) EventDeliveryStatuses(ctx context.Context, tenantID string, eventIDs []string) (map[string]string, error)
- func (s *PostgresStore) GetDelivery(ctx context.Context, tenantID, id string) (domain.WebhookDelivery, error)
- func (s *PostgresStore) GetEndpoint(ctx context.Context, tenantID, id string) (domain.WebhookEndpoint, error)
- func (s *PostgresStore) GetEndpointStats(ctx context.Context, tenantID string) ([]EndpointStats, error)
- func (s *PostgresStore) GetEvent(ctx context.Context, tenantID, id string) (domain.WebhookEvent, error)
- func (s *PostgresStore) ListDeliveries(ctx context.Context, tenantID, eventID string) ([]domain.WebhookDelivery, error)
- func (s *PostgresStore) ListDeliveriesByEndpoint(ctx context.Context, tenantID, endpointID string, limit int) ([]domain.WebhookDelivery, error)
- func (s *PostgresStore) ListEndpoints(ctx context.Context, tenantID string) ([]domain.WebhookEndpoint, error)
- func (s *PostgresStore) ListEvents(ctx context.Context, tenantID string, limit int) ([]domain.WebhookEvent, error)
- func (s *PostgresStore) ListPendingDeliveries(ctx context.Context, limit int) ([]domain.WebhookDelivery, error)
- func (s *PostgresStore) RotateEndpointSecret(ctx context.Context, tenantID, id, newSecret string, gracePeriod time.Duration) (domain.WebhookEndpoint, error)
- func (s *PostgresStore) SetEncryptor(enc *crypto.Encryptor)
- func (s *PostgresStore) TryRetryLock(ctx context.Context) (DispatchLock, bool, error)
- func (s *PostgresStore) UpdateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
- func (s *PostgresStore) UpdateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
- type ReplayDeliveryResult
- type ReplayResult
- type RetryLocker
- type RotateSecretResult
- type Service
- func (s *Service) CreateEndpoint(ctx context.Context, tenantID string, input CreateEndpointInput) (CreateEndpointResult, error)
- func (s *Service) CreateEndpointTx(ctx context.Context, tx *sql.Tx, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
- func (s *Service) DeleteEndpoint(ctx context.Context, tenantID, id string) error
- func (s *Service) Dispatch(ctx context.Context, tenantID, eventType string, payload map[string]any) error
- func (s *Service) DispatchFromOutbox(ctx context.Context, outboxRowID, tenantID, eventType string, ...) error
- func (s *Service) EventBus() *EventBus
- func (s *Service) EventDeliveryStatuses(ctx context.Context, tenantID string, eventIDs []string) (map[string]string, error)
- func (s *Service) GetEndpointStats(ctx context.Context, tenantID string) ([]EndpointStats, error)
- func (s *Service) GetEvent(ctx context.Context, tenantID, id string) (domain.WebhookEvent, error)
- func (s *Service) ListDeliveries(ctx context.Context, tenantID, eventID string) ([]domain.WebhookDelivery, error)
- func (s *Service) ListEndpointDeliveries(ctx context.Context, tenantID, endpointID string, limit int) (domain.WebhookEndpoint, []domain.WebhookDelivery, error)
- func (s *Service) ListEndpoints(ctx context.Context, tenantID string) ([]domain.WebhookEndpoint, error)
- func (s *Service) ListEvents(ctx context.Context, tenantID string, limit int) ([]domain.WebhookEvent, error)
- func (s *Service) Replay(ctx context.Context, tenantID, eventID string) (ReplayResult, error)
- func (s *Service) ReplayDelivery(ctx context.Context, tenantID, endpointID, deliveryID string) (ReplayDeliveryResult, error)
- func (s *Service) RetryPendingDeliveries(ctx context.Context) error
- func (s *Service) RotateSecret(ctx context.Context, tenantID, endpointID string) (RotateSecretResult, error)
- func (s *Service) StartRetryWorker(ctx context.Context, interval time.Duration)
- func (s *Service) UpdateEndpoint(ctx context.Context, tenantID, id string, input UpdateEndpointInput) (domain.WebhookEndpoint, error)
- type Store
- type StreamFrame
- type UpdateEndpointInput
Constants ¶
const ( OutboxPending = "pending" OutboxDispatched = "dispatched" OutboxFailed = "failed" )
outbox row statuses.
const MaxOutboxAttempts = 15
MaxOutboxAttempts is the DLQ threshold. After this many failed attempts a row becomes terminal ('failed') and is no longer retried automatically. Backoff ramp (see outboxBackoff) totals ~72h across 15 attempts.
const SecretRotationGracePeriod = 72 * time.Hour
SecretRotationGracePeriod is how long a rotated-out secret keeps signing alongside its replacement. Velox uses 72h; Stripe's hosted equivalent caps at 24h (https://docs.stripe.com/webhooks/signature). The intentional deviation: self-hosted Velox deployments often have slower deploy cadences (manual rolls, no central CI fleet), so 24h would force a rushed cutover for tenants whose ops team checks the webhook receivers once a week. 72h covers a typical "find the change request → ship → verify" loop without the rushed-deploy footgun. Compromise case: a leaked secret stays usable for up to 72h after the operator rotates — bounded but not instant. Tighten to 24h if a tenant- configurable cap is justified by a real DP request.
Variables ¶
var ErrStaleDeliveryMark = errors.New("webhook: stale delivery mark (row no longer pending)")
ErrStaleDeliveryMark is returned when a delivery mark matched no pending row — the row was resolved by another worker after this worker's lease expired. The stale writer drops its result.
Functions ¶
This section is empty.
Types ¶
type AuditWriter ¶
type AuditWriter interface {
Log(ctx context.Context, tenantID, action, resourceType, resourceID, resourceLabel string, metadata map[string]any) error
}
AuditWriter is the narrow audit surface webhook handler uses.
type CreateEndpointInput ¶
type CreateEndpointResult ¶
type CreateEndpointResult struct {
Endpoint domain.WebhookEndpoint `json:"endpoint"`
Secret string `json:"secret"` // Shown once
}
type DeliveriesResponse ¶
type DeliveriesResponse struct {
RootEventID string `json:"root_event_id"`
Deliveries []DeliveryView `json:"deliveries"`
}
DeliveriesResponse wraps the timeline. We surface root_event_id so the dashboard can confirm it received the original-pivot's chain (matters when the operator clicked Replay from a clone — the chain is still rooted at the original).
type DeliveryView ¶
type DeliveryView struct {
ID string `json:"id"`
EventID string `json:"event_id"`
// EndpointID stays for machine use, but the timeline is an operator
// surface: EndpointURL/EndpointDescription are what let a human
// answer "which of my receivers is this?" without cross-referencing
// an opaque id against a page that never displays ids. Resolved by
// the store's historical join, so they survive endpoint deletion.
EndpointID string `json:"endpoint_id"`
EndpointURL string `json:"endpoint_url"`
EndpointDescription string `json:"endpoint_description"`
AttemptNo int `json:"attempt_no"`
Status string `json:"status"`
StatusCode int `json:"status_code"`
ResponseBody string `json:"response_body"`
Error string `json:"error"`
RequestPayloadSHA256 string `json:"request_payload_sha256"`
AttemptedAt time.Time `json:"attempted_at"`
CompletedAt *time.Time `json:"completed_at"`
NextRetryAt *time.Time `json:"next_retry_at"`
IsReplay bool `json:"is_replay"`
ReplayEventID string `json:"replay_event_id"`
}
DeliveryView is the dashboard-facing delivery row: it carries the per-attempt facts the timeline needs (attempt number, status, response body, timestamps) plus the request_payload_sha256 the diff viewer uses to flag "payload identical between attempts" (the common case for Stripe-style replays).
Snake_case throughout — pinned by TestWireShape_WebhookEventDeliveries.
type DispatchLock ¶
type DispatchLock interface {
Release()
}
DispatchLock is a held cluster-wide lock the dispatcher must release.
type DispatchLocker ¶
type DispatchLocker interface {
TryDispatcherLock(ctx context.Context) (DispatchLock, bool, error)
}
DispatchLocker gates the dispatcher tick on a cluster-wide advisory lock. Row-level FOR UPDATE SKIP LOCKED already prevents double-delivery when two dispatchers race, but the lock avoids both replicas issuing the same claim query every 2s when only one drain worker is actually needed — less churn on the connection pool and on webhook_outbox's index scan. Nil Locker disables gating (single-replica / test mode).
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher drains the webhook_outbox by invoking Service.Dispatch for each pending row. It is the bridge between the durable outbox (what producers enqueue) and the existing per-endpoint delivery pipeline (webhook_events + webhook_deliveries). Handler semantics: a row is marked 'dispatched' once Service.Dispatch returns nil, which means the event has been persisted and queued to all matching endpoints — per-endpoint HTTP retry is then owned by Service.StartRetryWorker, independent of the outbox.
func NewDispatcher ¶
func NewDispatcher(outbox *OutboxStore, svc *Service, cfg DispatcherConfig) *Dispatcher
func (*Dispatcher) Config ¶
func (d *Dispatcher) Config() DispatcherConfig
Start runs the dispatcher loop until ctx is cancelled. Intended to be launched as a goroutine from cmd/velox during boot, alongside the existing webhook retry worker. Config exposes the resolved configuration for the invariant test.
func (*Dispatcher) SetLocker ¶
func (d *Dispatcher) SetLocker(locker DispatchLocker)
SetLocker enables leader gating on the dispatcher tick.
func (*Dispatcher) Start ¶
func (d *Dispatcher) Start(ctx context.Context)
type DispatcherConfig ¶
type DispatcherConfig struct {
// Interval is the poll cadence between ProcessBatch calls. Default 2s if zero.
Interval time.Duration
// BatchSize bounds how many rows are claimed per tick. Default 25 if zero.
BatchSize int
// BatchTimeout bounds how long a single batch is allowed to run before its
// tx is cancelled (releasing row locks). Default 30s if zero.
BatchTimeout time.Duration
}
DispatcherConfig controls the outbox dispatcher loop.
type EndpointDeliveryView ¶ added in v0.2.0
type EndpointDeliveryView struct {
ID string `json:"id"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
// IsReplay marks rows born from an operator replay (event-wide or
// single-receiver) — the drill-down badges them the same way the
// event timeline does.
IsReplay bool `json:"is_replay"`
ReplayOfEventID string `json:"replay_of_event_id,omitempty"`
Status string `json:"status"`
StatusCode int `json:"status_code"`
AttemptCount int `json:"attempt_count"`
ResponseBody string `json:"response_body"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
NextRetryAt *time.Time `json:"next_retry_at"`
}
EndpointDeliveryView is the endpoint drill-down row: one receiver's history across events, so each row names the business fact it carried (event_type) rather than the receiver (which is the page's own subject). Snake_case pinned by TestWireShape_EndpointDeliveries.
type EndpointStats ¶
type EndpointStats struct {
EndpointID string `json:"endpoint_id"`
TotalDeliveries int `json:"total_deliveries"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
SuccessRate float64 `json:"success_rate"`
}
EndpointStats holds delivery statistics for a single webhook endpoint.
type EventBus ¶
type EventBus struct {
// contains filtered or unexported fields
}
EventBus is the in-memory pub/sub the SSE handler uses to live-tail new events as they're dispatched. We deliberately do NOT poll the DB on a tick — that approach burns the partial-index cache on every poll and lags ~1s behind dispatch. Instead, the Service's Dispatch path (and the OutboxDispatcher's success callback) calls EventBus.Publish synchronously; subscribers receive frames within goroutine-scheduling latency.
Slow subscribers do NOT block the publisher — Publish does a non-blocking send and drops frames for any subscriber whose buffer is full. Dropped frames are not retried; the dashboard's snapshot-at- connect compensates by re-fetching recent events on reconnect, so a disconnected client picks back up cleanly.
Per-tenant fan-out keeps the bus a no-op for any tenant with zero dashboards open. We index subscribers by tenant_id at registration so Publish doesn't iterate every tenant's subscriber list.
func NewEventBus ¶
func NewEventBus() *EventBus
NewEventBus returns an empty in-memory bus. Single instance per process is sufficient — the API server is the only producer (replicas use the leader-elected outbox dispatcher; replicas with no HTTP traffic produce no live events to fan out).
func (*EventBus) Publish ¶
func (b *EventBus) Publish(tenantID string, frame StreamFrame)
Publish fans a frame out to every subscriber for the frame's tenant. Non-blocking by construction: a slow subscriber drops the frame silently rather than back-pressuring the dispatcher. The bus is hot- path adjacent (called from Service.Dispatch) so we keep the lock hold tight and read-only.
func (*EventBus) Subscribe ¶
func (b *EventBus) Subscribe(tenantID string) (<-chan StreamFrame, func())
Subscribe registers a new subscriber for the given tenant. Returns the receive channel and an unsubscribe func the caller MUST call (typically deferred at request-handler scope) so the goroutine scheduler can collect the closed connection.
The buffer size of 32 is empirically picked: at ~5 frames/s steady- state per tenant (a busy production workload), the buffer absorbs ~6s of producer bursts before drops. A subscriber that can't drain at that rate is almost certainly a dead connection — better to drop than block the dispatcher.
type HTTPClient ¶
HTTPClient is the interface for making HTTP requests (mockable in tests).
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
func NewHandler ¶
func (*Handler) EventRoutes ¶
EventRoutes is the Week 6 real-time event surface, mounted at /v1/webhook_events. Lives here (rather than alongside Routes) so the route table stays tightly scoped to "the live-tail dashboard" and router.go can mount it under its own auth scope without dragging in the endpoint-management permissions.
Critical: chi/v5 dispatches by registration order. We register /stream BEFORE /{id} so a literal "stream" path doesn't get captured as an ID and route to the deliveries handler. See docs/design-create-preview.md for the canonical write-up.
Note: the SSE handler (streamEvents) IS exposed here for tests and dev-time mounting, but the production router mounts /stream SEPARATELY outside the /v1 block so it can skip the global 30s middleware.Timeout (which would kill any long-lived stream). See internal/api/router.go for the production mount.
func (*Handler) SetAuditLogger ¶
func (h *Handler) SetAuditLogger(a AuditWriter)
SetAuditLogger wires audit on webhook endpoint lifecycle + manual event replays. Endpoint URL changes + secret rotations are integration-security events; manual replays change downstream state and need forensic trail.
func (*Handler) StreamHandler ¶
func (h *Handler) StreamHandler() http.HandlerFunc
StreamHandler is the chi-compatible handler for the live-tail SSE stream. Exported so router.go can mount it on a route block that skips the 30s timeout middleware applied to /v1/* routes.
type OutboxDispatcher ¶
type OutboxDispatcher struct {
// contains filtered or unexported fields
}
OutboxDispatcher satisfies domain.EventDispatcher by persisting each event as a pending row in webhook_outbox. The Dispatcher worker drains it.
This is the durable drop-in replacement for calling Service.Dispatch directly: producers get persist-before-return semantics, and the actual HTTP delivery is done by the worker asynchronously — same as today, just with a durable queue in between.
func NewOutboxDispatcher ¶
func NewOutboxDispatcher(outbox *OutboxStore) *OutboxDispatcher
func (*OutboxDispatcher) Dispatch ¶
func (d *OutboxDispatcher) Dispatch(ctx context.Context, tenantID, eventType string, payload map[string]any) error
Dispatch enqueues a pending outbox row. Returns an error if the insert fails — callers should treat that as a fatal enqueue failure, since the event would otherwise be lost. The current fireEvent call sites ignore the error because under the pre-outbox scheme nothing could be done; tightening that is a follow-up once the outbox becomes the default.
type OutboxHandler ¶
OutboxHandler is called once per claimed row by ProcessBatch. Returning nil means the row is dispatched successfully; returning an error schedules a retry (or DLQ once MaxOutboxAttempts is reached).
type OutboxRow ¶
type OutboxRow struct {
ID string
TenantID string
Livemode bool // Carries the producer tx's mode; dispatcher propagates this into ctx so delivery hits only same-mode endpoints.
EventType string
Payload map[string]any
Status string
Attempts int
NextAttemptAt time.Time
LastError string
CreatedAt time.Time
DispatchedAt *time.Time
}
OutboxRow is a single queued outbound-event intent. Produced inside the business-op transaction, drained by the dispatcher.
type OutboxStore ¶
type OutboxStore struct {
// contains filtered or unexported fields
}
OutboxStore persists webhook-event emission intents.
func NewOutboxStore ¶
func NewOutboxStore(db *postgres.DB) *OutboxStore
func (*OutboxStore) Enqueue ¶
func (s *OutboxStore) Enqueue(ctx context.Context, tx *sql.Tx, tenantID, eventType string, payload map[string]any) (string, error)
Enqueue inserts a pending outbox row inside the caller's tx. Use this from a business-op store method so the event is persisted atomically with the state change — if the tx rolls back, no event is emitted; if it commits, the dispatcher will eventually deliver.
func (*OutboxStore) EnqueueStandalone ¶
func (s *OutboxStore) EnqueueStandalone(ctx context.Context, tenantID, eventType string, payload map[string]any) (string, error)
EnqueueStandalone opens its own tenant-scoped tx to insert the outbox row. Use when the caller has no tx already in scope — still durable because the insert commits before return, but not atomic with the preceding business op. Prefer Enqueue whenever a tx is available.
func (*OutboxStore) FailedCount ¶
func (s *OutboxStore) FailedCount(ctx context.Context) (int64, error)
FailedCount returns rows currently in the DLQ — used for alerting. If this grows, an endpoint is persistently broken or a producer is emitting malformed events.
func (*OutboxStore) PendingCount ¶
func (s *OutboxStore) PendingCount(ctx context.Context) (int64, error)
PendingCount returns the current number of rows awaiting dispatch. Intended for metrics (operator gauge) — not on the hot path.
func (*OutboxStore) ProcessBatch ¶
func (s *OutboxStore) ProcessBatch(ctx context.Context, limit int, handler OutboxHandler) (int, error)
ProcessBatch locks up to `limit` due pending rows across all tenants, hands them to `handler`, and marks each row based on the handler's result — all within a single tx. Row locks held for the tx's duration prevent concurrent dispatchers from double-delivering (`FOR UPDATE SKIP LOCKED`).
Returns the number of rows processed (attempted, regardless of outcome). Callers should set a sensible query timeout on ctx so a stuck handler can't hold locks indefinitely.
func (*OutboxStore) TryDispatcherLock ¶
func (s *OutboxStore) TryDispatcherLock(ctx context.Context) (DispatchLock, bool, error)
TryDispatcherLock tries to acquire the cluster-wide advisory lock that gates the outbox dispatcher tick. Returns (lock, true, nil) on success; caller defers lock.Release. Returns (nil, false, nil) if another replica holds it. Implements webhook.DispatchLocker.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
func NewPostgresStore ¶
func NewPostgresStore(db *postgres.DB) *PostgresStore
func (*PostgresStore) CreateDeliveriesForEvent ¶
func (s *PostgresStore) CreateDeliveriesForEvent(ctx context.Context, tenantID, eventID string, endpointIDs []string, birthLease time.Duration) ([]domain.WebhookDelivery, error)
CreateDeliveriesForEvent batch-creates born-leased delivery rows for an event that already exists (Replay: the clone event commits in CreateReplayEvent first). One tx — a replay whose fan-out partially failed would otherwise deliver to a subset with no record of the rest.
func (*PostgresStore) CreateDelivery ¶
func (s *PostgresStore) CreateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
func (*PostgresStore) CreateEndpoint ¶
func (s *PostgresStore) CreateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
func (*PostgresStore) CreateEndpointTx ¶
func (s *PostgresStore) CreateEndpointTx(ctx context.Context, tx *sql.Tx, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
CreateEndpointTx inserts a webhook endpoint inside an existing tx. Used by recipe.Service.Instantiate so a recipe that wants a default outbound receiver lands its endpoint atomically with the rest of the recipe.
func (*PostgresStore) CreateEvent ¶
func (s *PostgresStore) CreateEvent(ctx context.Context, tenantID string, event domain.WebhookEvent) (domain.WebhookEvent, error)
func (*PostgresStore) CreateEventWithDeliveries ¶
func (s *PostgresStore) CreateEventWithDeliveries(ctx context.Context, tenantID string, event domain.WebhookEvent, endpointIDs []string, birthLease time.Duration, outboxRowID string) (domain.WebhookEvent, []domain.WebhookDelivery, error)
CreateEventWithDeliveries creates the event AND all its delivery rows in ONE transaction (P5, CreateEndpointTx precedent) — the old shape committed the event, then each fan-out goroutine inserted its own delivery row: a crash in between minted an event no delivery row referenced, so no endpoint ever received it and nothing retried. Delivery rows are BORN LEASED (next_retry_at = now()+birthLease): the in-process goroutine owns the first attempt inside the lease, and the retry worker is the crash backstop that picks the row up at lease expiry — never a NULL next_retry_at double-POST race.
outboxRowID, when non-empty, marks the webhook_outbox row dispatched INSIDE the same tx (handler-owns-mark): a crash after this commit but before the old separate outbox mark used to re-run the handler and mint a DUPLICATE event with a fresh id that receivers cannot dedupe.
func (*PostgresStore) CreateReplayEvent ¶
func (s *PostgresStore) CreateReplayEvent(ctx context.Context, tenantID, originalEventID string) (domain.WebhookEvent, error)
CreateReplayEvent clones an existing event row into a new event whose replay_of_event_id points at the original. The clone reuses the original's event_type and payload byte-for-byte (so the diff viewer can verify "payload identical, only timestamps differ" — Stripe's "Resend" semantics).
func (*PostgresStore) DeleteEndpoint ¶
func (s *PostgresStore) DeleteEndpoint(ctx context.Context, tenantID, id string) error
func (*PostgresStore) EventDeliveryStatuses ¶ added in v0.2.0
func (s *PostgresStore) EventDeliveryStatuses(ctx context.Context, tenantID string, eventIDs []string) (map[string]string, error)
EventDeliveryStatuses rolls each event's deliveries up to one status. Precedence: any pending → "pending" (work in flight), else any failed → "failed" (something needs attention — a partial success must not read as done), else "delivered". Replaces the age heuristic the SSE snapshot used to guess with ("<24h old ⇒ pending"), which showed a settled event as pending all day and would have shown a permanently-failed delivery as delivered once it aged past the ladder.
func (*PostgresStore) GetDelivery ¶ added in v0.2.0
func (s *PostgresStore) GetDelivery(ctx context.Context, tenantID, id string) (domain.WebhookDelivery, error)
func (*PostgresStore) GetEndpoint ¶
func (s *PostgresStore) GetEndpoint(ctx context.Context, tenantID, id string) (domain.WebhookEndpoint, error)
func (*PostgresStore) GetEndpointStats ¶
func (s *PostgresStore) GetEndpointStats(ctx context.Context, tenantID string) ([]EndpointStats, error)
func (*PostgresStore) GetEvent ¶
func (s *PostgresStore) GetEvent(ctx context.Context, tenantID, id string) (domain.WebhookEvent, error)
GetEvent returns a single event by id (tenant-scoped via RLS).
func (*PostgresStore) ListDeliveries ¶
func (s *PostgresStore) ListDeliveries(ctx context.Context, tenantID, eventID string) ([]domain.WebhookDelivery, error)
func (*PostgresStore) ListDeliveriesByEndpoint ¶ added in v0.2.0
func (s *PostgresStore) ListDeliveriesByEndpoint(ctx context.Context, tenantID, endpointID string, limit int) ([]domain.WebhookDelivery, error)
ListDeliveriesByEndpoint is the endpoint drill-down: one receiver's delivery history, newest first. The events join hydrates each row with the business fact it carried (event_type) and its replay pivot — this surface lists deliveries ACROSS events, so a row must be readable without a second fetch. INNER JOIN is safe here: a delivery row is created in the same tx as its event (or referencing an event that already committed, on the replay path), so the FK target always exists.
func (*PostgresStore) ListEndpoints ¶
func (s *PostgresStore) ListEndpoints(ctx context.Context, tenantID string) ([]domain.WebhookEndpoint, error)
func (*PostgresStore) ListEvents ¶
func (s *PostgresStore) ListEvents(ctx context.Context, tenantID string, limit int) ([]domain.WebhookEvent, error)
func (*PostgresStore) ListPendingDeliveries ¶
func (s *PostgresStore) ListPendingDeliveries(ctx context.Context, limit int) ([]domain.WebhookDelivery, error)
func (*PostgresStore) RotateEndpointSecret ¶
func (s *PostgresStore) RotateEndpointSecret(ctx context.Context, tenantID, id, newSecret string, gracePeriod time.Duration) (domain.WebhookEndpoint, error)
func (*PostgresStore) SetEncryptor ¶
func (s *PostgresStore) SetEncryptor(enc *crypto.Encryptor)
SetEncryptor configures AES-256-GCM encryption for webhook signing secrets at rest. When set (non-noop), Create/UpdateEndpointSecret encrypt the raw whsec_ secret before INSERT; Get/ListEndpoints decrypt it after SELECT so the Dispatch path can sign with the plaintext key. Without this, the raw signing key is stored in plaintext — a DB dump yields webhook-forging capability against every tenant's receivers.
func (*PostgresStore) TryRetryLock ¶
func (s *PostgresStore) TryRetryLock(ctx context.Context) (DispatchLock, bool, error)
ListPendingDeliveries atomically CLAIMS up to `limit` due deliveries and returns them. It is not a pure read: each returned row is leased by pushing next_retry_at one claim-lease into the future, and rows already locked by a concurrent worker are skipped (FOR UPDATE SKIP LOCKED). This makes the retry worker safe to run on multiple replicas — two workers never claim the same delivery, so a webhook isn't delivered twice per due-tick. The lease also provides crash recovery: if the claiming worker dies before overwriting the row's status, the lease (retryClaimLease — sized to the claim batch, P5) expires and another worker re-claims it. The window must exceed the per-attempt HTTP timeout (10s) by a wide margin so an in-flight delivery is never re-claimed underneath itself. TryRetryLock leader-gates the retry worker tick (LockKeyWebhookRetry).
func (*PostgresStore) UpdateDelivery ¶
func (s *PostgresStore) UpdateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
func (*PostgresStore) UpdateEndpoint ¶
func (s *PostgresStore) UpdateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
UpdateEndpoint mutates url / description / events / active WITHOUT touching the signing secret (Stripe's update shape: subscribers change the target or the event set without a receiver redeploy — pre-2026-07-05 the only mutation path was delete+recreate, which minted a new secret, and recipe-created endpoints were permanently dead: Active:false with a placeholder URL and no way to fix either). The caller (service) has already validated URL + event names.
type ReplayDeliveryResult ¶ added in v0.2.0
type ReplayDeliveryResult struct {
// EventID is the freshly-minted replay clone (replay_of set), same
// shape as an event-wide replay — the delivery timeline folds it
// into the original's chain.
EventID string `json:"event_id"`
// ReplayOf is the root original event of the audit chain.
ReplayOf string `json:"replay_of"`
// DeliveryID is the new delivery row created for the target
// endpoint — the drill-down highlights it on refetch.
DeliveryID string `json:"delivery_id"`
Status string `json:"status"`
}
ReplayDeliveryResult is the response for a single-receiver replay.
type ReplayResult ¶
type ReplayResult struct {
// EventID is the freshly-minted webhook_events row that's been
// queued for delivery. The dashboard's SSE tail will pick it up
// within a tick.
EventID string `json:"event_id"`
// ReplayOf is the ID of the original event whose payload was
// cloned. The dashboard groups the original + all replays under
// this pivot so the Deliveries timeline shows the full audit
// chain.
ReplayOf string `json:"replay_of"`
// Status is "queued" — the deliver fan-out runs asynchronously, so
// at response time we can only confirm the clone landed and
// matched at least one endpoint.
Status string `json:"status"`
}
ReplayResult is the response shape for POST /v1/webhook_events/{id}/replay. Returned to the dashboard so the live tail can highlight the new row and the Toast confirms what the operator just queued.
type RetryLocker ¶
type RetryLocker interface {
TryRetryLock(ctx context.Context) (DispatchLock, bool, error)
}
RetryLocker is the optional leader-gate the retry worker uses when the store provides it (PostgresStore does; unit-test fakes usually don't).
type RotateSecretResult ¶
type RotateSecretResult struct {
Secret string `json:"secret"`
SecondaryValidTill *time.Time `json:"secondary_valid_until,omitempty"`
}
RotateSecretResult carries the new secret and the expiry of the grace-period sibling. Exposed on the handler response so the dashboard can show "old secret valid until <time>" copy.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
func NewService ¶
func NewService(store Store, client HTTPClient) *Service
func NewTestService ¶
func NewTestService(store Store, client HTTPClient) *Service
NewTestService creates a service with synchronous delivery (no goroutines).
func (*Service) CreateEndpoint ¶
func (s *Service) CreateEndpoint(ctx context.Context, tenantID string, input CreateEndpointInput) (CreateEndpointResult, error)
func (*Service) CreateEndpointTx ¶
func (s *Service) CreateEndpointTx(ctx context.Context, tx *sql.Tx, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
CreateEndpointTx forwards to the store's tx-aware insert. Used by recipe.Service.Instantiate so a recipe with a default outbound endpoint commits atomically with the rest of the recipe.
func (*Service) DeleteEndpoint ¶
func (*Service) Dispatch ¶
func (s *Service) Dispatch(ctx context.Context, tenantID, eventType string, payload map[string]any) error
Dispatch creates a webhook event and delivers it to all matching endpoints.
Mode scoping: ListEndpoints runs under the caller's ctx livemode, which RLS already filters on. The explicit ep.Livemode == event.Livemode check below is defense-in-depth — if a future call path opens a bypass tx or the RLS predicate is relaxed, test-mode events must still never cross into a live endpoint (and vice versa). Cross-mode delivery would leak synthetic data into production monitoring.
func (*Service) DispatchFromOutbox ¶
func (s *Service) DispatchFromOutbox(ctx context.Context, outboxRowID, tenantID, eventType string, payload map[string]any) error
DispatchFromOutbox is Dispatch carrying the webhook_outbox row that produced this event, so the row's dispatched-mark commits ATOMICALLY with the event + delivery rows (handler-owns-mark, P5): the old separate mark meant a crash between the event commit and the outbox mark re-ran the handler next tick and minted a duplicate event with a fresh id — receivers cannot dedupe those. Empty outboxRowID = direct dispatch (no outbox row).
The endpoint fan-out is resolved FIRST (the event's livemode is the ctx's — the 0021 trigger stamps the row from the same session GUC) and the event + every matching delivery row are created in ONE store tx, rows born leased (birthLeaseWindow): the in-process goroutines own the first attempt; a crash any time after the commit leaves rows the retry worker picks up at lease expiry — never an event no delivery row references, never a NULL next_retry_at.
func (*Service) EventBus ¶
EventBus exposes the in-memory pub/sub backing the SSE stream. The handler subscribes per-request; the service publishes frames from Dispatch and the deliver path. Returns the same instance for the lifetime of the service so multiple handlers / cmd-side workers share one fan-out.
func (*Service) EventDeliveryStatuses ¶ added in v0.2.0
func (s *Service) EventDeliveryStatuses(ctx context.Context, tenantID string, eventIDs []string) (map[string]string, error)
EventDeliveryStatuses rolls each event's deliveries up to one dashboard status — see the Store method for the precedence contract.
func (*Service) GetEndpointStats ¶
GetEndpointStats returns delivery success/failure stats per endpoint.
func (*Service) GetEvent ¶
GetEvent fetches a single event by id (tenant-scoped via RLS at the store layer). Surfaced for the SSE handler's deliveries-list path so it can resolve the replay root before walking the timeline.
func (*Service) ListDeliveries ¶
func (s *Service) ListDeliveries(ctx context.Context, tenantID, eventID string) ([]domain.WebhookDelivery, error)
ListDeliveries returns deliveries for a specific event.
func (*Service) ListEndpointDeliveries ¶ added in v0.2.0
func (s *Service) ListEndpointDeliveries(ctx context.Context, tenantID, endpointID string, limit int) (domain.WebhookEndpoint, []domain.WebhookDelivery, error)
ListEndpointDeliveries returns one endpoint plus its delivery history (newest first). The endpoint lookup runs first so a bad id 404s rather than returning an empty list that reads as "healthy receiver, no traffic".
func (*Service) ListEndpoints ¶
func (*Service) ListEvents ¶
func (s *Service) ListEvents(ctx context.Context, tenantID string, limit int) ([]domain.WebhookEvent, error)
ListEvents returns recent webhook events for a tenant.
func (*Service) Replay ¶
Replay clones an existing webhook event into a fresh row (with replay_of_event_id pointing at the original) and dispatches it to every matching active endpoint. The clone is what gets delivered, so the original's deliveries are never mutated — every replay produces a brand-new row in the timeline. A second replay of the same original event is therefore not idempotent in the DB-row sense (it creates another clone), but is idempotent in the audit-trail sense: the original delivery history is preserved and the operator sees N distinct replay attempts on the timeline.
func (*Service) ReplayDelivery ¶ added in v0.2.0
func (s *Service) ReplayDelivery(ctx context.Context, tenantID, endpointID, deliveryID string) (ReplayDeliveryResult, error)
ReplayDelivery re-sends one delivery's event to THAT delivery's endpoint only — the industry replay unit (Stripe "Resend"/"Retry now", GitHub "Redeliver", Svix per-attempt resend). Event-wide Replay fans the clone out to every matching endpoint, which re-delivers to receivers that already succeeded; this path exists so fixing ONE broken receiver doesn't spray duplicates at the healthy ones.
Same audit chain as Replay: a clone event (replay_of = root original) plus a born-leased delivery row — the original delivery is history and is never mutated.
func (*Service) RetryPendingDeliveries ¶
RetryPendingDeliveries picks up deliveries due for retry and re-attempts them.
func (*Service) RotateSecret ¶
func (s *Service) RotateSecret(ctx context.Context, tenantID, endpointID string) (RotateSecretResult, error)
RotateSecret generates a new signing secret for an endpoint and returns it alongside the grace-period expiry. The previous secret stays valid for SecretRotationGracePeriod — dispatcher signs outbound events with BOTH secrets during the window (two v1= entries in Velox-Signature, Stripe multi-signature style) so receivers can stage a verifier update without breaking production traffic. After the window, only the new secret is used. The new secret is returned once to the caller (dashboard shows it, then it's no longer retrievable).
func (*Service) StartRetryWorker ¶
StartRetryWorker runs a background loop that retries pending deliveries on the given interval. It blocks until the context is cancelled. When the store exposes an advisory lock (production Postgres), each tick is leader-gated — the claim lease alone is a correct multi-replica guard, but gating makes its sizing non-critical (P5; same posture as both outbox dispatchers).
func (*Service) UpdateEndpoint ¶
func (s *Service) UpdateEndpoint(ctx context.Context, tenantID, id string, input UpdateEndpointInput) (domain.WebhookEndpoint, error)
UpdateEndpoint mutates url/description/events/active without rotating the signing secret (Stripe's update shape). This is also what makes recipe-created endpoints usable: they are created inactive with a placeholder URL, and this is the "point it at a real URL and activate" surface that previously didn't exist.
type Store ¶
type Store interface {
// Endpoints
CreateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
CreateEndpointTx(ctx context.Context, tx *sql.Tx, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
GetEndpoint(ctx context.Context, tenantID, id string) (domain.WebhookEndpoint, error)
// UpdateEndpoint mutates url/description/events/active without touching
// the signing secret (the PATCH shape; delete+recreate was the only
// mutation path before and it rotated the secret).
UpdateEndpoint(ctx context.Context, tenantID string, ep domain.WebhookEndpoint) (domain.WebhookEndpoint, error)
ListEndpoints(ctx context.Context, tenantID string) ([]domain.WebhookEndpoint, error)
DeleteEndpoint(ctx context.Context, tenantID, id string) error
// RotateEndpointSecret atomically moves the current signing secret into
// the secondary slot with `gracePeriod` left on its clock, and replaces
// the primary with `newSecret`. Runs under a single transaction with
// FOR UPDATE so a concurrent rotate can't lose the old secret. If
// gracePeriod is zero, no secondary is written (hard-replace).
RotateEndpointSecret(ctx context.Context, tenantID, id, newSecret string, gracePeriod time.Duration) (domain.WebhookEndpoint, error)
// Events
CreateEvent(ctx context.Context, tenantID string, event domain.WebhookEvent) (domain.WebhookEvent, error)
// CreateEventWithDeliveries creates the event and every fan-out
// delivery row (born leased) in ONE tx; outboxRowID, when set, marks
// the producing webhook_outbox row dispatched in the same tx (P5
// handler-owns-mark — see PostgresStore for the crash analysis).
CreateEventWithDeliveries(ctx context.Context, tenantID string, event domain.WebhookEvent, endpointIDs []string, birthLease time.Duration, outboxRowID string) (domain.WebhookEvent, []domain.WebhookDelivery, error)
// CreateDeliveriesForEvent batch-creates born-leased delivery rows
// for an EXISTING event (the replay path — the clone commits first).
CreateDeliveriesForEvent(ctx context.Context, tenantID, eventID string, endpointIDs []string, birthLease time.Duration) ([]domain.WebhookDelivery, error)
ListEvents(ctx context.Context, tenantID string, limit int) ([]domain.WebhookEvent, error)
// EventDeliveryStatuses rolls each event's deliveries up to one status:
// any pending → "pending", else any failed → "failed", else "delivered".
// Events with no deliveries are absent from the map (no endpoint matched).
EventDeliveryStatuses(ctx context.Context, tenantID string, eventIDs []string) (map[string]string, error)
GetEvent(ctx context.Context, tenantID, id string) (domain.WebhookEvent, error)
// CreateReplayEvent clones an existing event into a fresh event row
// with replay_of_event_id pointing back at the original. The clone
// goes through the normal Dispatch fan-out so each subscribed
// endpoint produces its own delivery row — visible alongside the
// original's deliveries on the dashboard timeline.
CreateReplayEvent(ctx context.Context, tenantID, originalEventID string) (domain.WebhookEvent, error)
// Deliveries
CreateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
UpdateDelivery(ctx context.Context, tenantID string, d domain.WebhookDelivery) (domain.WebhookDelivery, error)
ListDeliveries(ctx context.Context, tenantID, eventID string) ([]domain.WebhookDelivery, error)
GetDelivery(ctx context.Context, tenantID, id string) (domain.WebhookDelivery, error)
// ListDeliveriesByEndpoint is the endpoint drill-down query: every
// delivery sent to ONE receiver, newest first, each row hydrated with
// its event's type + replay pivot (the surface lists deliveries across
// events, so rows must be self-describing).
ListDeliveriesByEndpoint(ctx context.Context, tenantID, endpointID string, limit int) ([]domain.WebhookDelivery, error)
// Stats
GetEndpointStats(ctx context.Context, tenantID string) ([]EndpointStats, error)
// Retry support (cross-tenant, system-level)
ListPendingDeliveries(ctx context.Context, limit int) ([]domain.WebhookDelivery, error)
}
type StreamFrame ¶
type StreamFrame struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
LastAttemptAt *time.Time `json:"last_attempt_at"`
CreatedAt time.Time `json:"created_at"`
Livemode bool `json:"livemode"`
ReplayOfEventID *string `json:"replay_of_event_id"`
}
StreamFrame is the SSE-shaped projection of a webhook event. We use a flat snake_case struct (not the raw domain.WebhookEvent) because the dashboard contract is stable — adding a column to webhook_events shouldn't silently widen the wire shape.
The frame carries the latest delivery rollup we know about at emit time:
- status — the pub/sub-side aggregate ("dispatched" / "pending" / "failed"). The dashboard renders this directly; clicking the row fetches the per-attempt timeline via /v1/webhook_events/{id}/deliveries.
- last_attempt_at — best-effort time of the most recent dispatch attempt seen by the bus. NULL on the initial "snapshot" frames sent at connect time when the event hasn't been dispatched yet.
func FrameFromEvent ¶
func FrameFromEvent(e domain.WebhookEvent, status string, lastAttemptAt *time.Time) StreamFrame
FrameFromEvent builds a StreamFrame from a domain.WebhookEvent. status defaults to "pending" if not yet observed by the dispatcher. The frame inspects payload.customer_id (the convention all Velox internal Dispatchers honor) so the dashboard can group by customer without a JOIN.