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.