A webhook is an HTTP notification from another service. Unlike a browser request,
it usually has no logged-in user: verify the provider’s credentials before acting
on the notification. The webhook package authenticates each delivery, decodes
the event you bind, and acknowledges application acceptance.
Three values separate those responsibilities:
- A source verifies requests for one provider account and returns its envelope, the provider metadata surrounding the still-encoded event payload.
- An event associates an exact provider event key with a payload decoder. It holds no credentials and works with multiple compatible sources.
- A receiver binds events to your application handlers and sets request policy.
Use a provider adapter when one is shipped. Constructing an outbound mail, model, or payment driver neither creates a source nor registers an endpoint. Your application owns the public route and provider registration.
Binding Handlers
For Resend delivery notifications, import arkoren.dev/driver/resend/resendwebhook
and arkoren.dev/core/http/webhook. Configure the endpoint in Resend and load its
signing secret—not the sending API key. See the
adapter setup for installation and verification.
source, err := resendwebhook.New(signingSecret)
Construction checks the secret’s format; verification happens on each request. Focused fragments keep construction errors visible and assume success. Handle those errors before using the returned values or registering routes.
With context imported and an existing application service messages, accept
only the delivered event:
accept := func(ctx context.Context, delivery webhook.Delivery[resendwebhook.Email]) error {
return messages.RecordDelivered(ctx, delivery.Payload.EmailID)
}
messages.RecordDelivered is application-owned and must tolerate duplicates.
Return nil only when you have accepted responsibility for the notification.
Every handler error answers 503, even if some work has already committed; core
does not roll back your work. A 503 permits redelivery but cannot make the
provider retry. Use queued handling for
slow work rather than making the provider wait.
Bind that function while constructing the receiver:
handler, err := webhook.Handler(source, func(receiver *webhook.Builder[resendwebhook.Envelope]) {
receiver.Handle(resendwebhook.EmailDelivered, accept)
})
The handler receives the authenticated delivery ID, exact provider event type, and decoded email metadata. It does not receive the raw request or credentials. Each key has one binding; registering it twice is an error, not fan-out.
With your existing router r, mount the returned router.Handler directly:
r.Post("/webhooks/resend", handler)
After acceptance returns nil, the receiver writes 200 with an empty body. Authenticated unbound events also receive 200 without calling your handler. Acceptance is not proof that downstream work finished. Choose delivery policies before relying on redelivery, deduplication, or queue durability.
Custom Sources
If no adapter implements your provider’s protocol, supply a verifier with
webhook.NewSource. Core supplies no signature algorithm or replay protection
for custom sources. Prefer the provider’s supported verifier rather than inventing
a signature format.
For a hypothetical shipping provider in your application, define the envelope
using encoding/json/jsontext:
type Envelope struct {
ID string `json:"id"`
Kind string `json:"kind"`
Data jsontext.Value `json:"data"`
}
The provider-specific verifyAndParse function below is your code, not an Arkoren
helper. It must authenticate the exact received bytes, implement the provider’s
timestamp/replay rules, and return an envelope only after authentication. For a
known invalid request, wrap both webhook.ErrRejected and your native sentinel,
for example fmt.Errorf("%w: %w", webhook.ErrRejected, ErrBadSignature).
Leave transient verification failures unclassified: they answer 503 rather than
rejecting a potentially genuine notification.
With net/http imported and your account’s secret already loaded:
source := webhook.NewSource(func(r *http.Request, body []byte) (webhook.Delivery[Envelope], error) {
envelope, err := verifyAndParse(r, body, secret)
if err != nil {
return webhook.Delivery[Envelope]{}, err
}
return webhook.Delivery[Envelope]{
ID: envelope.ID,
Type: envelope.Kind,
Payload: envelope,
}, nil
})
The verifier receives the bounded body exactly as sent; r.Body is already spent.
Do not decode and re-encode the body before verifying a body signature. Headers,
method, URL, and r.Context() remain available for protocols that need them.
Do not select a business action or trust payload fields before verification.
The receiver reads at most 1 MiB by default and rejects larger bodies with 413 before the source runs. An outer body-limit middleware can lower that ceiling. See body limits before increasing it.
NewSource retains the function without calling it or checking credentials.
Your provider constructor must validate credentials; Source.Err and Handler
only detect a missing verifier. Its function, credentials, and captured clients
are shared across requests and must support concurrent use.
Typed Events
Keep reusable event definitions separate from configured sources. For the shipping envelope above, use a small payload with the fields your handler needs:
type Shipment struct {
ID string `json:"shipment_id"`
Tracking string `json:"tracking_number"`
}
With json imported from encoding/json/v2, define the provider’s exact key:
var ShipmentDispatched = webhook.NewEvent("shipment.dispatched", func(envelope Envelope) (Shipment, error) {
var shipment Shipment
err := json.Unmarshal(envelope.Data, &shipment)
return shipment, err
})
Only a bound, authenticated event reaches this decoder. Decoders must not perform
network I/O; keep remote lookups in acceptance or a worker. Ordinary decoder
errors answer 503, allowing a later schema fix to accept a redelivery. An
authenticated decoder can return ErrSkip to acknowledge without acceptance;
the skip rules differ from errors
returned by handlers.
Event keys are case-sensitive provider strings, not queue names or diagnostic
categories. Empty keys, control characters, and nil decoders are invalid.
Types connect a handler to its payload, not to a particular account or secret.
Bind ShipmentDispatched through receiver.Handle in the same way as the shipped
Resend event above.
Shared Bindings
A library can publish its source constructor and credential-free event
definitions. To share application bindings, return a webhook.BuilderFunc rather
than hiding endpoint policy inside the source. With an application-owned
acceptShipment of type func(context.Context, webhook.Delivery[Shipment]) error:
bindings := func(receiver *webhook.Builder[Envelope]) {
receiver.Handle(ShipmentDispatched, acceptShipment)
}
handler, err := webhook.Handler(source, bindings)
Additional callbacks run once in order on the same construction draft, so the application can add receipt policy without redefining the binding. Publication copies binding and rejection containers, not functions or captured dependencies. There is no receiver lifecycle; the owners of those dependencies manage them.
Handler reports invalid sources, events, bindings, or policy with ErrInvalid.
At least one event must be bound. The first invalid setting is retained, later
callbacks still run, and a supplied nil callback panics. Fix construction errors
before serving requests rather than waiting for a provider delivery to reveal them.
For every function and option, see the
webhook API reference.