AnnouncementsIntroducing Arkoren

    Menu

    Delivery Policies

    Choose receipt retention, queued acceptance, failure responses, and operating limits for a webhook receiver.

    What's on this page
    1. Reduce duplicate handling with explicit receipt identities and retention without assuming exactly-once delivery.
    2. Queue verified deliveries using application-owned payloads and understand the acknowledgement boundary.
    3. Interpret HTTP outcomes, bound request bodies, and observe failures without exposing provider data.
    On this page

    A provider can redeliver after your server accepts an event, including when the response is lost. It can also send distinct events about the same resource. Choose what counts as acceptance and make its effects safe to repeat before enabling duplicate suppression or moving work to a queue.

    These examples assume the Resend source and typed accept function from Receiving Webhooks. Import arkoren.dev/core/http/webhook and arkoren.dev/driver/resend/resendwebhook. Each receiver construction below is an alternative policy, not a sequence of redefinitions to paste into one scope. Your application handles construction errors before mounting the returned handler.

    Duplicate Receipts

    Deduplicate remembers successfully accepted delivery IDs in a configured cache. A recorded ID receives 200 without decoding or acceptance. Deduplication is disabled by default and is best effort, not an exactly-once guarantee: cache errors fail open and concurrent copies can both run. Make the handler idempotent, for example by committing an event ID and its business effect in the same application transaction.

    With cache imported from arkoren.dev/core/service/cache and time from the standard library, choose a namespace and retention for a new receiver:

    receipts := cache.NewNamespace[bool]("webhook.resend.primary", func(namespace *cache.NamespaceBuilder) {
    	namespace.TTL(24 * time.Hour)
    })

    Twenty-four hours is an example policy, not a provider guarantee. Match retention to your provider’s retry and manual-redelivery window. Zero TTL means no expiry; there is no automatic 72-hour fallback. Use separate, noncolliding physical names for unrelated accounts or receivers sharing a cache.

    With your existing *cache.Cache named store, attach the receipts:

    handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
    	receiver.Handle(resendwebhook.EmailDelivered, accept)
    	receiver.Deduplicate(store, receipts)
    })

    The receiver authenticates first, checks the ID before decoding, and records it after acceptance succeeds but before writing 200. A failed acceptance does not write a receipt. An empty ID bypasses suppression; use the adapter’s stable event ID, not an email ID or retry-attempt ID.

    A failed cache check still runs acceptance, and a failed receipt write still answers 200. Cache eviction, expiry, or process-local storage can therefore admit duplicates. The receiver neither starts nor stops the cache; its owner manages that lifecycle. Nil caches and invalid namespaces fail receiver construction.

    Preserving Receipts

    Preserve deployed namespace names, separators, and retention when upgrading. Changing a receipt key forgets prior acceptance and can admit replays. A route rename does not change keys: namespaces are not derived from routes, provider names, secrets, or diagnostic labels.

    Only delivery IDs are encoded with unpadded base64url. Namespace names and separators are literal, so different definitions alone do not guarantee isolated physical keys. See cache identity safety. The default separator is :; explicitly set namespace.Separator(".") in the namespace callback to retain receipts written with the former dot default.

    If you are migrating from the former scope-based webhook API, reproduce its prefix rather than choosing a new name. With encoding/base64 imported, oldScope and oldRetention are your deployed values:

    name := "webhook:v1:" + base64.RawURLEncoding.EncodeToString([]byte(oldScope))
    receipts := cache.NewNamespace[bool](name, func(namespace *cache.NamespaceBuilder) {
    	namespace.Separator(":")
    	namespace.TTL(oldRetention)
    })

    Use this namespace in Deduplicate instead of the new-receiver namespace above. Preserve the required replay window or explicitly migrate existing receipts; there is no automatic lookup under old names. General namespace upgrades are covered in preserving cache names.

    Queued Handling

    If acceptance needs slow email, API, or database work, hand a verified delivery to a queue. Dispatch acknowledges when the queue driver accepts the message, not when a worker finishes. There is no built-in inbox or outbox: that acknowledgement is only as durable as your chosen queue driver. Workers must still tolerate duplicates, including work submitted before a lost response or a failed receipt write.

    Queued messages can outlive a deployment. Keep the payload in a small type you control rather than depending on a provider SDK’s future JSON encoding:

    type DeliveredEmail struct {
    	EmailID string `json:"email_id"`
    }

    Use Event.Map to retain the provider decoder while projecting its result into that application-owned type:

    delivered := resendwebhook.EmailDelivered.Map(func(email resendwebhook.Email) (DeliveredEmail, error) {
    	return DeliveredEmail{EmailID: email.EmailID}, nil
    })

    The original event remains unchanged. Its decoder runs before conversion; a decoder error prevents conversion. Map preserves the exact provider key and does not replace the delivery ID or type. Keep conversion free of I/O and shared payload mutation; functions and captured values must support concurrent calls. An ordinary conversion error answers 503 at the decode stage. An authenticated ErrSkip instead acknowledges without queueing or recording a receipt.

    Import arkoren.dev/core/service/queue. With your configured *queue.Dispatcher named dispatcher, declare a typed job and bind it instead of Handle:

    job := queue.NewJob[webhook.Delivery[DeliveredEmail]]("email-delivered")
    handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
    	receiver.Dispatch(delivered, dispatcher, job)
    })

    The queued payload includes the provider ID, type, and mapped payload under the stable JSON members id, type, and payload. The queue creates its own message ID. Evolve application payloads compatibly while old messages remain queued, and remember that queue message-size limits can be smaller than the webhook body limit. If the event already returns the payload type you want to store, dispatch it directly without Map. A nil dispatcher, invalid job, or invalid mapped event fails receiver construction.

    With context imported, your existing *queue.Consumer named consumer can perform the work later. Register before starting the consumer:

    err := consumer.Handle(job, func(ctx context.Context, delivery webhook.Delivery[DeliveredEmail]) error {
    	return messages.RecordDelivered(ctx, delivery.Payload.EmailID)
    })

    Here messages is your idempotent application service, not an Arkoren helper. Consumer failures follow queue delivery and retry policy, not a second HTTP answer to the provider. Queue verified deliveries, not raw bodies: signature headers are not included in the job.

    Acknowledgement Outcomes

    The receiver processes one event per request and writes its own response. Provider retry rules remain provider-owned: neither 503 nor any other answer guarantees redelivery. A handler can commit part of its work and then fail; an error is not evidence of rollback.

    Outcome HTTP Response New Receipt
    Acceptance returns nil, including an intentional no-op 200, empty body Write attempted if enabled and the ID is nonempty
    Previously recorded ID 200, empty body None
    Authenticated unbound event 200, empty body None
    Source, decoder, or conversion returns ErrSkip 200, empty body None
    Source error matches Reject Mapped 4xx None
    Source error wraps ErrRejected without a matching override 400 None
    Body exceeds the effective limit 413 None
    Malformed body read 400 None
    Body read interrupted by cancellation or timeout 503 None
    Other source error, ordinary decode/conversion error, or any acceptance error 503 None

    Return ErrSkip from a source only after authentication succeeds; otherwise a forged request could be acknowledged as valid. Decoders and conversions already run after authentication. In acceptance, return nil for an intentional no-op. Every acceptance error—including ErrSkip and permanent queue errors—answers 503 and leaves the ID unrecorded.

    Failure responses use fixed Problem Details messages without the underlying cause, and the receiver never redirects. Oversized bodies retain the native request.ErrBodyTooLarge 413 problem. Because these responses are already committed, application-wide error mapping cannot replace them. Response-writer hooks still run.

    Returned receipt errors still match their class (webhook.ErrRejected or webhook.ErrUnavailable) and original cause through errors.Is. Oversized receipts also match request.ErrBodyTooLarge. Request logging can retain the cause without exposing it to the sender; an error mapper can still affect log classification, not the committed response.

    Rejection Overrides

    Use Reject only for known-invalid verification errors. Do not map a temporary lookup or verification outage to 4xx: the provider might stop redelivering a genuine event. For Resend, an alternative receiver can answer 401 for a known bad signature, with net/http imported:

    handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
    	receiver.Handle(resendwebhook.EmailDelivered, accept)
    	receiver.Reject(http.StatusUnauthorized, resendwebhook.ErrInvalidSignature)
    })

    Mappings apply only to source errors and match through errors.Is. Calls accumulate; the first matching mapping wins. Unmapped ErrRejected answers 400; other source errors answer 503.

    Reject requires at least one non-nil error and a 4xx status, but refuses 408 and 429 because they mean retry later, 406 because Mailgun stops retrying, and 410 because Standard Webhooks providers disable the endpoint. Invalid mappings make Handler return ErrInvalid at construction.

    Body Limits

    The receiver reads the body before authentication, so its size limit also bounds unauthenticated requests. The default is 1 MiB. If provider notifications fit within a smaller allowance, lower it while constructing the receiver:

    handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
    	receiver.Handle(resendwebhook.EmailDelivered, accept)
    	receiver.MaxBodySize(256 << 10)
    })

    This permits at most 256 KiB; larger requests answer 413 without calling the source. Zero selects the 1 MiB default; negative sizes fail construction. An outer body-limit middleware remains a ceiling: the smaller limit wins. Raising MaxBodySize cannot raise that outer ceiling. Choose a larger allowance only for a provider payload that needs it, and keep ingress limits and queue capacity consistent with that choice.

    Receiver Diagnostics

    A reachable endpoint can still reject every request after a secret change. Connect your application’s event bus to distinguish rejections, processing failures, and authenticated events without bindings. With an existing bus named bus:

    handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
    	receiver.Handle(resendwebhook.EmailDelivered, accept)
    	receiver.Events(bus)
    	receiver.Label("resend-primary")
    })

    Events(nil), the default, publishes nothing. Labels default to empty and are not validated or redacted: choose low-cardinality application values without credentials, personal identifiers, or provider-controlled data. A label is not a receipt identity and does not alter deduplication.

    Topic Payload When Published
    webhook.Rejected (http.webhook.rejected) RejectedEvent: label, reason, status Any 4xx outcome
    webhook.Failed (http.webhook.failed) FailedEvent: label, stage Any 503 outcome
    webhook.Ignored (http.webhook.ignored) IgnoredEvent: label, reason Authenticated unbound event

    Skips and duplicate acknowledgements publish none of these topics. Categories describe local processing, not provider business outcomes:

    Field Type Constants and String Values
    RejectionReason RejectionReasonBodyTooLarge = "body_too_large"; RejectionReasonUnreadableBody = "unreadable_body"; RejectionReasonVerify = "verify"
    FailureStage FailureStageRead = "read"; FailureStageVerify = "verify"; FailureStageDecode = "decode"; FailureStageAccept = "accept"
    IgnoreReason IgnoreReasonUnbound = "unbound"

    These constants belong to webhook. Unclassified source errors, including remote verification outages and envelope-processing failures, report FailureStageVerify; classified source rejections report RejectionReasonVerify. Map errors belong to the decode stage; every acceptance error belongs to the accept stage. Empty zero values remain unspecified, not default categories.

    With context imported and your existing logger, subscribe for the receiver’s lifetime:

    unsubscribe, err := bus.Listen(webhook.Failed, func(ctx context.Context, failure webhook.FailedEvent) error {
    	logger.WarnContext(ctx, "webhook processing unavailable",
    		"receiver", failure.Label, "stage", string(failure.Stage))
    
    	return nil
    })

    Handle the registration error and call unsubscribe when retiring the receiver, not when a short-lived setup function returns. Listeners run synchronously after the outcome is known and cannot change it; send slow alert work to a worker.

    Diagnostic payloads omit bodies, headers, provider IDs, event types, and causes. That omission does not sanitize request context values or returned receipt errors, and normal HTTP problem instances can still contain a request path. Select safe logging attributes instead of forwarding whole contexts or errors.

    If upgrading from plain-string category fields, compare typed constants or use explicit conversions such as string(failure.Stage) for string-only APIs. String and JSON category bytes and member names remain unchanged. This change does not require renaming provider keys, labels, or receipt namespaces.

    Operating Receivers

    Meet the provider’s response deadline by keeping acceptance short and bounding remote verification I/O. Core handles one event per request; for provider batches, decode a batch payload and split it in the consumer. Challenge responses, interactive decisions, and decoding that fetches remote resources need application-owned handling rather than this receiver pipeline.

    Before deploying, check the boundary with signed provider fixtures:

    • A valid delivery returns 200 and reaches the intended handler or queue job.
    • An invalid signature never reaches acceptance; a verification outage answers 503.
    • Repeating an ID suppresses the second sequential acceptance when receipts are enabled.
    • Failed acceptance can run again; cache failures and concurrent deliveries remain safe.
    • Body-limit and decoder failures produce the intended status and diagnostic stage.

    Use handler.Record(signedRequest) to exercise a fixture without a network; close the returned response body when the test finishes reading it. For queue handoff, queuetest.New() from arkoren.dev/core/service/queue/queuetest records dispatches without a broker; FailWith(err) simulates acceptance failure. That fake proves submission, not external queue durability. Test worker effects independently and use testing/synctest for timestamp-sensitive fixtures. Monitor cache failures as well as receiver outcomes, since receipt failures intentionally do not fail the delivery.

    For every function and option, see the webhook API reference.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive