Documentation
¶
Overview ¶
Package webhook receives inbound webhook notifications: it reads the raw body within a size limit, authenticates the request with a provider Source, hands the delivery to the handler the application bound to its event, and acknowledges acceptance with 200 after that handler succeeds. Authenticated unbound events, duplicates and deliberate preaccept skips are also acknowledged without running a handler.
Overview ¶
Three parts have separate owners:
- A Source authenticates the requests of one provider account and parses them into an envelope of type E. It holds the credentials, so a vendor package returns one from its own constructor.
- An Event names one provider event type and decodes the envelope into the payload type P. It holds no credentials: a vendor package, library or ark publishes it as an immutable package-level variable, and the same Event works with any number of Sources.
- The application calls Handler and binds handlers to events with Builder.Handle or Builder.Dispatch. Cache, event bus, body limit and rejection statuses are the application's policy, not the Source's.
There is no endpoint name and no receiver object: Handler returns a router.Handler for router.Post, and configuration errors are returned when it is built.
Receiving an event ¶
The acmewebhook package in these examples is hypothetical, not an existing integration. Its constructor validates the signing secret and its verifier wraps ErrRejected for known invalid signatures or envelopes; unexpected failures remain unclassified and answer 503.
Example:
source, err := acmewebhook.New(signingSecret)
if err != nil {
return err
}
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[acmewebhook.Envelope]) {
receiver.Handle(acmewebhook.OrderPaid, acceptOrder)
})
if err != nil {
return err
}
r.Post("/webhooks/orders", handler)
Add another Handle call in the same callback to receive another event. The compiler infers Handler's envelope type from source and Handle's payload type from the event and callback; explicit generic arguments are unnecessary.
Request flow ¶
- Read the body, at most Builder.MaxBodySize bytes (and never more than an outer bodylimit middleware allows).
- Authenticate with the VerifyFunc of the Source. Nothing is selected or handled before this succeeds.
- Find the handler bound to the delivery's event type.
- If Builder.Deduplicate is on and the delivery has an ID, skip a delivery already handled.
- Decode the envelope with the Event.
- Call the handler.
- Remember the ID, then answer 200 with an empty body.
Statuses ¶
- Success, an authenticated delivery skipped with ErrSkip by the Source or the decoder, a delivery of an event type nobody bound, and a duplicate all answer exactly 200 with an empty body. An unbound type publishes Ignored with a reason and no type.
- A body over the limit answers 413. A body that cannot be read answers 400 when it is malformed, and 503 when the read was cut short by cancellation or a timeout.
- A verify error matching a Builder.Reject mapping answers that 4xx; one wrapping ErrRejected answers 400; any other verify error answers 503.
- A decode error answers 503. A schema or API-version mismatch can be fixed by a deploy, so it is not treated as permanent.
- Every handler error answers 503, including ErrSkip and permanent queue errors. The handler decides nothing about 4xx.
Return nil for a deliberate no-op in acceptance: it acknowledges and records the ID when deduplication is enabled. An ErrSkip from the Source or decoder acknowledges before acceptance and does not record the ID.
Retries are the provider's policy and differ: some providers retry every non-2xx answer, others never redeliver a failed delivery automatically. A 503 therefore means "may be retried", never "will be retried".
Failure responses and errors ¶
Failure responses are rendered by the receiver itself as safe problem details without any cause, then committed, so an app-wide error mapper cannot change what the provider received. The returned error matches both its class (ErrRejected or ErrUnavailable) and the original cause with errors.Is, so logging keeps the cause. Middleware hooks around the response writer still run as usual, and a mapper can still affect how the returned error is classified in logs. Events never carry a body, header, ID, event type or cause.
Observing receipt categories ¶
Configure Builder.Events to observe Rejected, Failed and Ignored. Their payload fields use RejectionReason, FailureStage and IgnoreReason, respectively. These are string-backed categories with stable explicit values, not provider event keys. Their empty zero values remain unspecified, with no normalization. Delivery.Type, Event.Key and Builder.Label stay strings.
Compare against constants; convert explicitly for APIs requiring a string:
unsubscribe, err := bus.Listen(webhook.Failed, func(ctx context.Context, failed webhook.FailedEvent) error {
logger.ErrorContext(ctx, "webhook failed", "stage", string(failed.Stage))
return nil
})
if err != nil {
return err
}
defer unsubscribe()
Events omit request data and causes, but labels are application-controlled and unvalidated. Keep credentials and personal identifiers out of labels. Listener contexts retain request context values, and returned errors retain causes; neither is generally sanitized. HTTP problem instances can still include the request path. Skips and duplicates publish no Ignored event.
Configuration ownership and defaults ¶
Handler runs its construction callbacks once in order and then copies owned binding and rejection containers. Retaining a draft cannot change the published receiver. Services, functions, error values and closure captures remain shared; the receiver does not start or stop them. They must support concurrent requests. The omitted or zero body limit is 1 MiB; negative limits are invalid. Without explicit settings there is no deduplication, no event publication, no label and no custom rejection mapping. ErrRejected still selects 400 without a mapping.
Deduplication ¶
Builder.Deduplicate needs an explicit stable typed namespace. Return a stable provider event ID in Delivery.ID; an ID that changes per delivery attempt defeats suppression. The check is best effort: it fails open, concurrent duplicates can both be handled, and the ID is remembered only after the handler succeeded. Handlers must be idempotent. Changing the namespace forgets everything seen.
Not covered ¶
Delivery and retry guarantees belong to the provider; assume duplicates. Acceptance is not transactional: a handler that failed may have committed part of its work. Protocols whose response carries data (Slack challenges, interactive decision callbacks) are out of scope; wrap the handler and own the response. Decoding must not fetch data over the network, so providers whose notifications are thin must be handled by the application. Respect the provider's response deadline: hand slow work to a queue with Builder.Dispatch and return quickly. Acknowledgement is only as durable as the selected queue driver; no inbox or outbox is provided.
Mapping payloads for a queue ¶
Event.Map reuses a provider event's decoder to produce an application-owned DTO while keeping its key and Delivery metadata. It returns a new definition; the original stays reusable. Conversion must be pure and perform no I/O. Conversion errors follow the decoder's 503/ErrSkip policy, not acceptance's.
orderPaid := acmewebhook.OrderPaid.Map(func(order acmewebhook.Order) (OrderDTO, error) {
return OrderDTO{OrderID: order.OrderID}, nil
})
job := queue.NewJob[webhook.Delivery[OrderDTO]]("order-paid")
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[acmewebhook.Envelope]) {
receiver.Dispatch(orderPaid, dispatcher, job)
})
if err != nil {
return err
}
r.Post("/webhooks/orders", handler)
Publishing events from a library or ark ¶
A library validates its credentials in its Source constructor and publishes credential-free Events separately. For example, its event declarations can decode raw JSON without implementing any application policy:
package acmewebhook
import (
"encoding/json/jsontext"
"encoding/json/v2"
"arkoren.dev/core/http/webhook"
)
type Envelope struct {
ID string
Kind string
Data jsontext.Value
}
type Shipment struct {
ID string `json:"id"`
}
var ShipmentDelivered = webhook.NewEvent("shipment.delivered", func(envelope Envelope) (Shipment, error) {
var shipment Shipment
err := json.Unmarshal(envelope.Data, &shipment)
return shipment, err
})
An ark can export a BuilderFunc that binds its handlers, leaving policy to the application. The shipping package below is also hypothetical:
func Webhooks(store *Store) webhook.BuilderFunc[acmewebhook.Envelope] {
return func(receiver *webhook.Builder[acmewebhook.Envelope]) {
receiver.Handle(acmewebhook.ShipmentDelivered, store.MarkDelivered)
}
}
The application composes them with its own policy:
handler, err := webhook.Handler(source, shipping.Webhooks(store), func(receiver *webhook.Builder[acmewebhook.Envelope]) {
receiver.Deduplicate(store, cache.NewNamespace[bool]("shipping", func(space *cache.NamespaceBuilder) {
space.TTL(72 * time.Hour)
}))
})
if err != nil {
return err
}
r.Post("/webhooks/shipping", handler)
Index ¶
- Variables
- func Handler[E any](source Source[E], configure ...BuilderFunc[E]) (router.Handler, error)
- type AcceptFunc
- type Builder
- func (receiver *Builder[E]) Deduplicate(store *cache.Cache, space cache.Namespace[bool]) *Builder[E]
- func (receiver *Builder[E]) Dispatch[P any](definition Event[E, P], dispatcher *queue.Dispatcher, ...) *Builder[E]
- func (receiver *Builder[E]) Events(bus *event.Bus) *Builder[E]
- func (receiver *Builder[E]) Handle[P any](definition Event[E, P], accept AcceptFunc[P]) *Builder[E]
- func (receiver *Builder[E]) Label(label string) *Builder[E]
- func (receiver *Builder[E]) MaxBodySize(size int64) *Builder[E]
- func (receiver *Builder[E]) Reject(status int, errs ...error) *Builder[E]
- type BuilderFunc
- type Delivery
- type Event
- type FailedEvent
- type FailureStage
- type IgnoreReason
- type IgnoredEvent
- type RejectedEvent
- type RejectionReason
- type Source
- type VerifyFunc
Constants ¶
This section is empty.
Variables ¶
var ErrInvalid = errors.New("webhook: invalid configuration")
ErrInvalid is wrapped by errors from Handler, Event.Err and Source.Err for invalid definitions or settings, such as a nil function, a duplicate event binding, or a Builder.Reject status that is not allowed.
Example:
_, err := webhook.Handler(source)
if errors.Is(err, webhook.ErrInvalid) {
return fmt.Errorf("configure at least one webhook event: %w", err)
}
var ErrRejected = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/webhook:rejected") details.Title("Webhook Rejected") details.Detail("The webhook delivery is invalid and was rejected.") details.Status(http.StatusBadRequest) })
ErrRejected is the class of a delivery the receiver refuses with a 4xx status. A verifier may return it (or wrap it) for 400; Builder.Reject selects other 4xx statuses. A returned receipt error always matches it with errors.Is, whatever the status, and still matches the original cause.
Example:
failure := webhook.ErrRejected.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var ErrSkip = errors.New("webhook: delivery skipped")
ErrSkip is returned (or wrapped) by a VerifyFunc or an Event decoder for an authentic delivery that needs no processing, such as an event the provider sends for every account. The receiver acknowledges with 200 without calling the handler or recording the ID for deduplication. It must only be returned after authentication succeeded. From an AcceptFunc it has no special meaning: the delivery fails with 503 like any other accept error.
Example:
return webhook.Delivery[Envelope]{}, fmt.Errorf("ping event: %w", webhook.ErrSkip)
details.Type("tag:arkoren.dev,2026-09:core:http/webhook:unavailable") details.Title("Webhook Unavailable") details.Detail("The webhook delivery could not be processed now. Retry later.") details.Status(http.StatusServiceUnavailable) })
ErrUnavailable is the class of a delivery that could not be processed now and answers 503. A returned receipt error always matches it with errors.Is and still matches the original cause.
Example:
failure := webhook.ErrUnavailable.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var Failed = event.NewTopic[FailedEvent]("http.webhook.failed")
Failed is published when a delivery is answered 503 because it could not be processed now. Whether the provider retries is the provider's policy.
Example:
unsubscribe, err := bus.Listen(webhook.Failed, onFailed)
if err != nil {
return err
}
defer unsubscribe()
var Ignored = event.NewTopic[IgnoredEvent]("http.webhook.ignored")
Ignored is published when an authenticated delivery is acknowledged with 200 because no handler is bound for its event type.
Example:
unsubscribe, err := bus.Listen(webhook.Ignored, onIgnored)
if err != nil {
return err
}
defer unsubscribe()
var Rejected = event.NewTopic[RejectedEvent]("http.webhook.rejected")
Rejected is published when a delivery is answered with a 4xx status. It is published after the outcome is known; a listener cannot change the response.
Example:
unsubscribe, err := bus.Listen(webhook.Rejected, onRejected)
if err != nil {
return err
}
defer unsubscribe()
Functions ¶
func Handler ¶
Handler returns the receiver for one provider account, ready for router.Post. source authenticates requests; the callbacks bind handlers to events and set the application's policy. There is no endpoint name: a route is just a route.
Per request it reads the body within the limit, authenticates it with the source, finds the handler bound to the delivery's event type, optionally skips an already handled event ID, decodes the event, calls the handler, and answers 200 with an empty body only if the handler returned nil. See the package documentation for the status of every outcome.
It returns an error wrapping ErrInvalid for an invalid source, an invalid setting, a binding error, or when no event is bound. Nothing else is validated lazily at request time. A nil entry in configure panics. Callbacks run once synchronously in order, including on invalid input; an expanded nil or empty slice supplies none. Publication detaches owned binding and rejection containers, not services, error values, functions, or captures. The handler owns no dependency lifecycle; shared dependencies and captures must be safe for concurrent requests.
Example:
With cache imported from arkoren.dev/core/service/cache, store is a *cache.Cache. source verifies Envelope values, InvoicePaid decodes the invoice event, acceptInvoice handles it, and r is the application's router. The namespace's default zero TTL retains deduplication markers without expiry.
space := cache.NewNamespace[bool]("billing")
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[Envelope]) {
receiver.Handle(InvoicePaid, acceptInvoice)
receiver.Deduplicate(store, space)
})
if err != nil {
return fmt.Errorf("billing webhook: %w", err)
}
r.Post("/webhooks/billing", handler)
Types ¶
type AcceptFunc ¶
AcceptFunc handles one decoded delivery. The receiver answers 200 only after it returns nil. Every error, including ErrSkip and a queue permanent error, answers 503 so the provider may retry; acceptance is not transactional, so a callback that failed may already have committed part of its work. Do the minimum here, because the provider is waiting: hand slow work to a queue (see Builder.Dispatch). Return nil for an intentional no-op: it acknowledges the delivery and records its ID when deduplication is enabled. Returning ErrSkip is not a no-op here; it answers 503 like every other accept error.
Example:
accept := func(ctx context.Context, delivery webhook.Delivery[Invoice]) error {
return invoices.MarkPaid(ctx, delivery.Payload.ID)
}
type Builder ¶
type Builder[E any] struct { // contains filtered or unexported fields }
Builder configures Handler before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic, and an expanded nil or empty slice supplies none. The first invalid setting is kept and returned by Handler wrapping ErrInvalid. Retaining the draft cannot change a published handler: owned binding and rejection containers are copied, not services, functions, error values, or their captures. Shared dependencies must support concurrent requests. Methods return the same draft.
Example:
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[Envelope]) {
receiver.Handle(InvoicePaid, acceptInvoice)
receiver.MaxBodySize(256 << 10)
})
if err != nil {
return err
}
r.Post("/webhooks/billing", handler)
func (*Builder[E]) Deduplicate ¶
func (receiver *Builder[E]) Deduplicate(store *cache.Cache, space cache.Namespace[bool]) *Builder[E]
Deduplicate skips deliveries whose provider event ID was already handled, remembering IDs in store using space's TTL; zero TTL retains them forever.
space is required: a stable, explicit namespace that separates this receiver's IDs from others sharing the cache, such as "billing". It is never derived from the route, the provider or a secret, so renaming a route is safe and changing space deliberately forgets what was seen, allowing duplicates. Deliveries with an empty Delivery.ID are never suppressed. Names are used literally, not base64 encoded. IDs alone are base64url encoded. The namespace defaults to colon joins. Set Separator(".") explicitly to keep markers written with the former dot default; no fallback or migration occurs. Every separator is literal, including empty concatenation. Callers sharing a cache must choose names and separators whose resulting keys differ for unrelated deliveries; different namespaces do not guarantee isolation.
The ID is checked before decoding and remembered after the handler succeeded and before the 200 is written, so a failed handler does not suppress a later attempt. This is best effort: a cache error fails open toward handling, concurrent deliveries of one ID can both pass the check, and a failure to remember is ignored. Handlers must be idempotent. A nil store or an invalid or zero namespace is invalid.
Without this setting, deduplication is disabled. The cache remains shared; this receiver does not start or stop it. Repeated calls replace store and space.
Example:
space := cache.NewNamespace[bool]("webhook.billing", func(space *cache.NamespaceBuilder) {
space.TTL(24 * time.Hour)
})
receiver.Deduplicate(store, space)
func (*Builder[E]) Dispatch ¶
func (receiver *Builder[E]) Dispatch[P any]( definition Event[E, P], dispatcher *queue.Dispatcher, job queue.Job[Delivery[P]], ) *Builder[E]
Dispatch binds definition to a queue job: the delivery becomes the payload of job through dispatcher, and the receiver answers 200 once the queue driver accepted the message. Acknowledgement is as durable as that driver.
The job payload is the Delivery with its stable JSON tags, so the job keeps the provider event ID and type; the queue still generates its own message ID. Queue limits on message size may be below Builder.MaxBodySize. A payload type that must survive across releases while messages are queued should be an application-owned DTO rather than a vendor SDK type; Event.Map reuses the provider decoder for that conversion. A nil dispatcher, an invalid job or an invalid event is invalid. The dispatcher remains shared; its owner manages any required lifecycle independently of this receiver.
Example:
invoicePaid := acmewebhook.InvoicePaid.Map(func(invoice acmewebhook.Invoice) (InvoiceDTO, error) {
return InvoiceDTO{ID: invoice.ID}, nil
})
job := queue.NewJob[webhook.Delivery[InvoiceDTO]]("invoice-paid")
receiver.Dispatch(invoicePaid, dispatcher, job)
func (*Builder[E]) Events ¶
Events publishes Rejected, Failed and Ignored to bus. Nil, the default, publishes nothing. Calls replace the shared bus; the receiver neither clones it nor owns its listeners' lifecycle. Listeners cannot change receipts.
Example:
receiver.Events(bus)
func (*Builder[E]) Handle ¶
func (receiver *Builder[E]) Handle[P any](definition Event[E, P], accept AcceptFunc[P]) *Builder[E]
Handle binds accept to definition: a delivery whose type is definition's key is decoded and passed to accept after authentication. One event has one owner, so binding a key twice is invalid, and there is no fan-out. Events that are not bound are acknowledged and ignored. An invalid event or a nil accept is invalid. Decoder and accept functions and their captures remain shared, not cloned; they must support concurrent requests.
Example:
receiver.Handle(acmewebhook.InvoicePaid, func(
ctx context.Context,
delivery webhook.Delivery[acmewebhook.Invoice],
) error {
return invoices.MarkPaid(ctx, delivery.Payload.ID)
})
func (*Builder[E]) Label ¶
Label names the receiver in published events, to tell receivers apart. It is a short, low-cardinality diagnostic string chosen by the application, not an identity: it is never part of a deduplication key. The default is empty. Calls replace the label without validation or redaction. Do not include credentials, personal identifiers, or provider-controlled values.
Example:
receiver.Label("billing")
func (*Builder[E]) MaxBodySize ¶
MaxBodySize limits the body to size bytes; a larger body answers 413 request.ErrBodyTooLarge. Zero, the default, means 1 MiB. An outer bodylimit middleware remains a ceiling: the smaller limit applies. A negative size is invalid.
Example:
receiver.MaxBodySize(256 << 10)
func (*Builder[E]) Reject ¶
Reject answers status when the verifier's error matches (errors.Is) any of errs, so a caller can map a signature failure to 401 and a malformed payload to 400. Calls accumulate; the first matching mapping in call order wins.
status must be a 4xx. 406 (Mailgun stops retrying), 410 (Standard Webhooks disables the endpoint), 408 and 429 (both mean "retry later") are refused, as is a call without errs or with a nil error; Handler then returns an error wrapping ErrInvalid.
The error slice is copied, but the error values remain shared; do not mutate their matching behavior after publication. With no mappings, ErrRejected still answers 400 and unclassified verify errors answer 503.
Example:
receiver.Reject(http.StatusUnauthorized, payment.ErrInvalidSignature)
type BuilderFunc ¶
BuilderFunc configures the policy and bindings of Handler. It is an alias, so ordinary functions and slices of the identical function type remain usable. Handler calls callbacks once, synchronously in order on one fresh draft, even when a setting is invalid. Nil entries panic; expanded nil or empty slices supply no callbacks. Publication copies owned containers, not bound functions, services or their captures.
Example:
configure := []webhook.BuilderFunc[Envelope]{
func(receiver *webhook.Builder[Envelope]) {
receiver.Handle(InvoicePaid, acceptInvoice)
},
func(receiver *webhook.Builder[Envelope]) {
receiver.MaxBodySize(256 << 10)
},
}
handler, err := webhook.Handler(source, configure...)
if err != nil {
return err
}
r.Post("/webhooks/billing", handler)
type Delivery ¶
type Delivery[T any] struct { // ID is the provider's event ID. It must be stable across the provider's // retries of the same event, never an ID of one delivery attempt. Empty // disables deduplication for the delivery. // // Example: // delivery := webhook.Delivery[Invoice]{ID: "evt-42", Type: "invoice.paid", Payload: invoice} // logger.DebugContext(ctx, "invoice delivery received", "has_id", delivery.ID != "") ID string `json:"id"` // Type is the opaque provider event key, such as "invoice.paid". It is // matched exactly to [Event.Key], not normalized into an owned category. // // Example: // delivery := webhook.Delivery[Envelope]{ID: envelope.ID, Type: envelope.Kind, Payload: envelope} // return delivery, nil // from the authenticated verifier Type string `json:"type"` // Payload is the envelope from a [Source] or the decoded event in a handler. // Reference-bearing values are not deep-copied; handlers own any mutations // and must not race with other users of shared data. // // Example: // return invoices.MarkPaid(ctx, delivery.Payload.ID) Payload T `json:"payload"` }
Delivery is one authenticated inbound notification.
A Source returns a Delivery whose Payload is the provider envelope; a handler bound with Builder.Handle receives a Delivery whose Payload is the event's decoded type. The JSON tags are stable so a Delivery can be the payload of a queue job (see Builder.Dispatch).
Example:
func(ctx context.Context, delivery webhook.Delivery[Invoice]) error {
return invoices.MarkPaid(ctx, delivery.Payload.ID)
}
type Event ¶
type Event[E, P any] struct { // contains filtered or unexported fields }
Event names one provider event type and decodes the envelope of type E into the payload type P. It is an immutable, credential-free definition, usually a package-level variable published by a vendor package, a library or an ark, so the same Event works with any number of configured Source values. The compiler ties a handler to P; it does not check that an Event suits a particular account or secret.
The key is opaque to Arkoren: it is the provider's own string, not a job or topic name. The zero Event is invalid.
Example:
var InvoicePaid = webhook.NewEvent("invoice.paid", func(envelope Envelope) (Invoice, error) {
return decode[Invoice](envelope.Data)
})
func NewEvent ¶
NewEvent returns an Event for the provider event key. The key must not be empty or contain control characters, and decode must not be nil; the first problem is kept and reported by Event.Err and Builder.Handle. decode is called after authentication, only for a bound event, and must not perform network I/O. An error from decode answers 503 unless it wraps ErrSkip, which acknowledges the authenticated delivery without handling it.
Example:
var PullRequest = webhook.NewEvent("pull_request", func(envelope Envelope) (PullRequestEvent, error) {
return decode[PullRequestEvent](envelope.Body)
})
func (Event[E, P]) Err ¶
Err reports why the Event is invalid, wrapping ErrInvalid, or nil. A zero Event, which was not built by NewEvent, is invalid.
Example:
if err := InvoicePaid.Err(); err != nil {
return err
}
func (Event[E, P]) Key ¶
Key returns the provider event key.
Example:
key := acmewebhook.InvoicePaid.Key() // "invoice.paid"
func (Event[E, P]) Map ¶
Map returns a new Event with the same provider key, decoding with event before converting its payload from P to Q. Use it to project a provider's payload into an application-owned DTO without repeating envelope decoding or losing the delivery ID and type in Builder.Dispatch. Neither function runs during construction; an original decoder error prevents convert from running.
The original Event is unchanged. Both Events remain credential-free and reusable with any compatible Source. Functions and their captures are retained, not copied; convert must be safe for concurrent calls and must not perform I/O or mutate shared payload data. Conversion errors are decode-stage failures: 503 unless they wrap ErrSkip, which acknowledges without calling acceptance or recording the ID for deduplication.
An invalid or zero base Event keeps its error ahead of a nil convert. A nil convert on a valid Event is invalid. Event.Err and registration report the first error wrapping ErrInvalid, without invoking either function. The example uses a hypothetical provider package, acmewebhook.
Example:
invoicePaid := acmewebhook.InvoicePaid.Map(func(invoice acmewebhook.Invoice) (InvoiceDTO, error) {
return InvoiceDTO{ID: invoice.ID}, nil
})
job := queue.NewJob[webhook.Delivery[InvoiceDTO]]("invoice-paid")
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[acmewebhook.Envelope]) {
receiver.Dispatch(invoicePaid, dispatcher, job)
})
if err != nil {
return err
}
r.Post("/webhooks/billing", handler)
type FailedEvent ¶
type FailedEvent struct {
// Label is the application-selected [Builder.Label], empty if none. Keep it
// low-cardinality and free of credentials; it is not a provider event key.
//
// Example:
// logger.ErrorContext(ctx, "webhook failed", "label", failed.Label)
Label string
// Stage is where processing failed. See [FailureStage] for its values;
// empty is unspecified, not an implicit verification failure.
//
// Example:
// logger.ErrorContext(ctx, "webhook failed", "stage", string(failed.Stage))
Stage FailureStage
}
FailedEvent is the payload of Failed. It carries no cause, body, header, provider event ID or event type.
Example:
unsubscribe, err := bus.Listen(webhook.Failed, func(ctx context.Context, failed webhook.FailedEvent) error {
logger.ErrorContext(ctx, "webhook failed", "label", failed.Label, "stage", failed.Stage)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type FailureStage ¶
type FailureStage string
FailureStage identifies the processing step that could not complete, answered with 503. Handler emits only the constants below. The empty zero value is unspecified and stays empty, including in JSON; there is no default stage. The stage describes a local observation, not a provider retry guarantee.
Example:
failed := webhook.FailedEvent{Stage: webhook.FailureStageAccept}
logger.ErrorContext(ctx, "webhook failed", "stage", string(failed.Stage))
const ( // FailureStageRead identifies a body read interrupted by cancellation or a // timeout. Verification has not run, so this is not a signature failure. // // Example: // if failed.Stage == webhook.FailureStageRead { // logger.ErrorContext(ctx, "webhook read interrupted", "label", failed.Label) // } FailureStageRead FailureStage = "read" // FailureStageVerify identifies an unclassified verifier error. Errors // matched by [Builder.Reject] or [ErrRejected] are rejections instead. // // Example: // if failed.Stage == webhook.FailureStageVerify { // logger.ErrorContext(ctx, "webhook verifier unavailable", "label", failed.Label) // } FailureStageVerify FailureStage = "verify" // FailureStageDecode identifies an event decoder or [Event.Map] conversion // error. An authenticated [ErrSkip] from either step is acknowledged instead. // // Example: // if failed.Stage == webhook.FailureStageDecode { // logger.ErrorContext(ctx, "webhook decoding failed", "label", failed.Label) // } FailureStageDecode FailureStage = "decode" // FailureStageAccept identifies any [AcceptFunc] error, including [ErrSkip] // and permanent queue errors. Acceptance may already have committed work; // this category does not imply rollback or safe replay. // // Example: // if failed.Stage == webhook.FailureStageAccept { // logger.ErrorContext(ctx, "webhook acceptance failed", "label", failed.Label) // } FailureStageAccept FailureStage = "accept" )
type IgnoreReason ¶
type IgnoreReason string
IgnoreReason explains why an authenticated delivery was acknowledged without a bound handler. Handler currently emits only IgnoreReasonUnbound. The empty zero value is unspecified and remains empty in strings and JSON; it does not mean unbound. Skips and duplicates do not publish Ignored.
Example:
ignored := webhook.IgnoredEvent{Reason: webhook.IgnoreReasonUnbound}
logger.DebugContext(ctx, "webhook ignored", "reason", string(ignored.Reason))
const ( // IgnoreReasonUnbound identifies an authenticated provider event type with // no bound handler. It answers 200 without decoding or remembering its ID. // // Example: // if ignored.Reason == webhook.IgnoreReasonUnbound { // logger.DebugContext(ctx, "unbound webhook acknowledged", "label", ignored.Label) // } IgnoreReasonUnbound IgnoreReason = "unbound" )
type IgnoredEvent ¶
type IgnoredEvent struct {
// Label is the application-selected [Builder.Label], empty if none. It names
// the receiver for diagnostics, not the ignored event or deduplication scope.
//
// Example:
// logger.DebugContext(ctx, "webhook ignored", "label", ignored.Label)
Label string
// Reason is why the authenticated delivery was acknowledged without
// handling. See [IgnoreReason]; empty is unspecified, not an unbound default.
//
// Example:
// if ignored.Reason == webhook.IgnoreReasonUnbound {
// logger.DebugContext(ctx, "unbound webhook acknowledged", "label", ignored.Label)
// }
Reason IgnoreReason
}
IgnoredEvent is the payload of Ignored. It deliberately carries no event type, because the type is supplied by the provider.
Example:
unsubscribe, err := bus.Listen(webhook.Ignored, func(ctx context.Context, ignored webhook.IgnoredEvent) error {
logger.DebugContext(ctx, "webhook ignored", "label", ignored.Label, "reason", ignored.Reason)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type RejectedEvent ¶
type RejectedEvent struct {
// Label is the application-selected [Builder.Label], empty if none. It is
// not a provider identifier and must not contain credentials or personal data.
//
// Example:
// logger.WarnContext(ctx, "webhook rejected", "label", rejected.Label)
Label string
// Reason is the bounded rejection category. See [RejectionReason] for its
// values; empty means unspecified in a manually constructed payload.
//
// Example:
// if rejected.Reason == webhook.RejectionReasonVerify {
// logger.WarnContext(ctx, "webhook verification rejected", "status", rejected.Status)
// }
Reason RejectionReason
// Status is the selected 4xx status, including a mapped [Builder.Reject]
// status or the native 413. It is zero in a zero payload, not a default 400.
//
// Example:
// logger.WarnContext(ctx, "webhook rejected", "status", rejected.Status)
Status int
}
RejectedEvent is the payload of Rejected. It carries a label and categories, never a body, header, provider event ID, event type, or cause. Labels remain application-controlled and event contexts retain request context values; these omissions do not sanitize those values or returned receipt errors.
Example:
unsubscribe, err := bus.Listen(webhook.Rejected, func(ctx context.Context, rejected webhook.RejectedEvent) error {
logger.WarnContext(ctx, "webhook rejected", "label", rejected.Label, "reason", rejected.Reason)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
type RejectionReason ¶
type RejectionReason string
RejectionReason classifies a 4xx receipt without exposing the request or cause. Handler emits only the constants below. The empty zero value is unspecified; it is not normalized to another reason. Its string and JSON representations preserve the category bytes; use an explicit conversion for string-only APIs. It does not classify opaque provider keys such as Delivery.Type.
Example:
reason := webhook.RejectionReasonBodyTooLarge
rejected := webhook.RejectedEvent{Reason: reason, Status: http.StatusRequestEntityTooLarge}
logger.WarnContext(ctx, "webhook rejected", "reason", string(rejected.Reason))
const ( // RejectionReasonBodyTooLarge identifies a body over the effective size // limit. The receiver commits the native 413 body-limit problem, not the // generic webhook rejection representation. // // Example: // if rejected.Reason == webhook.RejectionReasonBodyTooLarge { // logger.WarnContext(ctx, "webhook body exceeds limit", "label", rejected.Label) // } RejectionReasonBodyTooLarge RejectionReason = "body_too_large" // RejectionReasonUnreadableBody identifies a malformed body read, answered // with 400. Cancellation and timeout are failures at [FailureStageRead], not // malformed-request rejections. // // Example: // if rejected.Reason == webhook.RejectionReasonUnreadableBody { // logger.WarnContext(ctx, "malformed webhook body", "label", rejected.Label) // } RejectionReasonUnreadableBody RejectionReason = "unreadable_body" // RejectionReasonVerify identifies a verifier error classified by // [Builder.Reject] or [ErrRejected]. The selected 4xx is [RejectedEvent.Status]; // an unclassified verifier error instead reports [FailureStageVerify]. // // Example: // if rejected.Reason == webhook.RejectionReasonVerify { // logger.WarnContext(ctx, "webhook verification rejected", "status", rejected.Status) // } RejectionReasonVerify RejectionReason = "verify" )
type Source ¶
type Source[E any] struct { // contains filtered or unexported fields }
Source authenticates the requests of one provider account and parses them into envelopes of type E. It holds the account's credentials and nothing of the application's policy: cache, events and handlers belong to the Builder. A vendor package usually returns a Source from its own constructor, which validates the credentials; NewSource is for a verifier that is already complete. The zero Source is invalid. The verifier and its captured credentials or clients remain shared, not cloned or lifecycle-managed by the Source; they must support concurrent requests.
Example:
source := webhook.NewSource(verify)
if err := source.Err(); err != nil {
return err
}
func NewSource ¶
func NewSource[E any](verify VerifyFunc[E]) Source[E]
NewSource returns a Source that authenticates with verify. A nil verify is reported by Source.Err and by Handler. Construction neither calls verify nor validates its credentials; the provider constructor owns that validation. The function and its captures are retained, so they must support concurrent use.
Example:
source := webhook.NewSource(verify)
if err := source.Err(); err != nil {
return err
}
func (Source[E]) Err ¶
Err reports why source cannot be used, wrapping ErrInvalid: a nil or missing verify function. It returns nil for a usable Source.
Example:
if err := source.Err(); err != nil {
return fmt.Errorf("acme source: %w", err)
}
type VerifyFunc ¶
VerifyFunc authenticates one request and returns the delivery it carries.
r supplies the method, URL and headers (some providers sign the URL); its context is the request context. body is the raw body already read within the size limit, exactly as sent, and r.Body is spent. Nothing may be selected or trusted before authentication succeeds, although a provider may parse what the signature scheme needs.
An error wrapping ErrSkip, returned only after authentication succeeded, acknowledges the delivery without handling it. An error matching a Builder.Reject mapping or wrapping ErrRejected rejects it with a 4xx status. Any other error answers 503; whether it is retried is the provider's policy, not a guarantee of the receiver.
Example:
verify := func(r *http.Request, body []byte) (webhook.Delivery[Envelope], error) {
envelope, err := parse(body, r.Header.Get("X-Signature"), secret)
if err != nil {
return webhook.Delivery[Envelope]{}, fmt.Errorf("%w: %w", webhook.ErrRejected, err)
}
return webhook.Delivery[Envelope]{ID: envelope.ID, Type: envelope.Kind, Payload: envelope}, nil
}