Documentation
¶
Overview ¶
Package webhook receives a provider's webhooks safely: the signature is verified on the raw body before anything decodes it, deliveries outside the replay window are refused, and each delivery id is handled once.
r.Handle("POST /api/v1/webhooks/payments", webhook.Stripe("PAYMENTS_WEBHOOK_SECRET", onPayment))
func onPayment(ctx context.Context, d *webhook.Delivery) error {
ev, err := d.StripeEvent()
if err != nil {
return err
}
switch ev.Type {
case "checkout.session.completed":
// ...
}
return nil // an event the app ignores is a success too
}
The secret is read by name like every setting (pkg/env): .env files, the sealed credentials (`lidza credentials set production.NAME=...`), values saved at runtime, the process environment. It is read again at most once a minute, so a saved value takes effect without a restart. Without a secret every delivery is refused with 503 and a log line naming the setting: an endpoint never runs unverified.
Replies: 200 when the handler returned nil or the delivery was handled before; 401 for a missing or wrong signature or a timestamp outside the window; 400 for a malformed delivery; 413 past the body limit; 409 while the same delivery is being handled on another request; 500 when the handler failed (the provider retries); 503 without a secret or a delivery store.
Delivery ids are recorded in Postgres (the webhook_delivery table, created on first use) when the db pack runs, so every node sees them. Without it, dev and test keep them in a bounded map in memory; in production an endpoint needs the db pack or an explicit Store (Memory is per node: a retry reaching another node runs the handler again).
Index ¶
- Constants
- func HMACSignature(secret string, body []byte) string
- func MailgunSignature(key string, t time.Time, token string) json.RawMessage
- func StandardKey(secret string) ([]byte, error)
- func StandardSignature(key []byte, id string, ts time.Time, body []byte) string
- func StripeSignature(secret string, t time.Time, body []byte) string
- type Claim
- type Delivery
- type Endpoint
- func HMAC(setting, header string, h Handler, opts ...Option) *Endpoint
- func Mailgun(setting string, h Handler, opts ...Option) *Endpoint
- func Standard(setting string, h Handler, opts ...Option) *Endpoint
- func Stripe(setting string, h Handler, opts ...Option) *Endpoint
- func Token(setting, header string, h Handler, opts ...Option) *Endpoint
- func TokenField(setting, field string, h Handler, opts ...Option) *Endpoint
- type Handler
- type MailgunEvent
- type MemoryStore
- type Option
- func Base64() Option
- func ID(f func(r *http.Request, body []byte) string) Option
- func IDHeader(name string) Option
- func MaxBody(n int64) Option
- func Prefix(p string) Option
- func Retention(d time.Duration) Option
- func Timeout(d time.Duration) Option
- func Tolerance(d time.Duration) Option
- func WithStore(s Store) Option
- type PostgresStore
- type Store
- type StripeEvent
Constants ¶
const ( // DefaultTolerance is the replay window: a delivery whose signed // timestamp is further from now than this is refused. DefaultTolerance = 5 * time.Minute // DefaultMaxBody bounds the body; raise it with MaxBody for inbound // mail with attachments. DefaultMaxBody = 1 << 20 // DefaultTimeout bounds reading the body and running the handler. DefaultTimeout = 10 * time.Second // DefaultRetention is how long delivery ids are kept; a provider's // retries must fall inside it (Stripe retries for three days). DefaultRetention = 30 * 24 * time.Hour // MaxIDLength bounds a delivery id; a longer one is refused (400). MaxIDLength = 255 )
Defaults.
const StandardSecretPrefix = "whsec_"
StandardSecretPrefix starts a Standard Webhooks secret: whsec_ and the key in base64.
Variables ¶
This section is empty.
Functions ¶
func HMACSignature ¶
HMACSignature returns the hex HMAC-SHA256 of body under secret, the value an HMAC endpoint expects after its Prefix.
func MailgunSignature ¶
MailgunSignature returns the "signature" object of a Mailgun JSON webhook signed at t with the signing key; the same three values are the form fields of a form post.
func StandardKey ¶
StandardKey reads a Standard Webhooks secret ("whsec_<base64>") as the HMAC key; a secret without the prefix is used as it is.
func StandardSignature ¶
StandardSignature is the webhook-signature value of a delivery under the Standard Webhooks scheme (standardwebhooks.com): "v1," and the base64 HMAC-SHA256 of "<id>.<unix timestamp>.<body>" under key. The hooks pack signs with it; Standard verifies it.
Types ¶
type Delivery ¶
type Delivery struct {
// Body is the raw body, verified; decode it with Decode.
Body []byte
// ID identifies the delivery for deduplication ("" when the scheme
// carries none and no ID option is set).
ID string
// Timestamp is the signed time of the delivery, zero when the scheme
// carries none.
Timestamp time.Time
// Form holds the fields of a form post (Mailgun's form webhooks and
// inbound routes; files are left out), nil for other bodies.
Form url.Values
// Request is the incoming request; its body has been read.
Request *http.Request
}
Delivery is one verified request.
func (*Delivery) MailgunEvent ¶
func (d *Delivery) MailgunEvent() (MailgunEvent, error)
MailgunEvent decodes the "event-data" of a Mailgun JSON webhook. Form posts (inbound routes) have none: read Form instead.
func (*Delivery) StripeEvent ¶
func (d *Delivery) StripeEvent() (StripeEvent, error)
StripeEvent decodes the verified body as a Stripe event.
type Endpoint ¶
type Endpoint struct {
// contains filtered or unexported fields
}
Endpoint is an http.Handler for one provider's webhook. Build it with Stripe, Mailgun, HMAC, Token or TokenField and register it on a route.
func HMAC ¶
HMAC verifies an HMAC-SHA256 of the raw body in a request header, hex by default (Base64 for base64), after an optional Prefix: GitHub sends X-Hub-Signature-256: sha256=<hex>. The scheme carries no timestamp and no id; give one with IDHeader ("X-GitHub-Delivery") so repeats are dropped.
func Mailgun ¶
Mailgun verifies Mailgun's signature: the HMAC-SHA256 (hex) of timestamp and token, concatenated, under the webhook signing key. It reads them from the "signature" object of a JSON event webhook, or from the timestamp, token and signature fields of a form post (legacy webhooks, inbound routes). The timestamp must lie in the replay window; the delivery id is the token.
func Standard ¶
Standard verifies the Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature: space-separated "v1,<base64>" values, any matching) under the setting's secret ("whsec_..."), the scheme Līdza's hooks pack sends and Svix, Resend and others use. The timestamp must lie within the tolerance; webhook-id drops repeats.
func Stripe ¶
Stripe verifies Stripe's Stripe-Signature header: t=<unix>,v1=<hex>, the HMAC-SHA256 of "<t>.<body>" under the endpoint's signing secret (whsec_...), any of several v1 values matching. The timestamp must lie in the replay window. The delivery id is the event's id.
func Token ¶
Token compares a shared secret sent in a request header, after an optional Prefix ("Bearer "), with the setting's value in constant time. The token is not a signature: the body is not covered, so use it only for providers that offer nothing better, over HTTPS.
func TokenField ¶
TokenField compares a shared secret sent in the JSON body, at field (a dotted path: "token", "auth.key"), with the setting's value in constant time, as some DNS and monitoring providers send it. Like Token, it does not cover the rest of the body.
type Handler ¶
Handler handles one verified delivery. Returning nil acknowledges it (200) and records its id as handled; an error is a 500, the id is released and the provider's retry runs the handler again. The handler must be quick: long work goes to a job.
type MailgunEvent ¶
type MailgunEvent struct {
Event string `json:"event"` // "delivered", "failed", "opened"
ID string `json:"id"`
Timestamp float64 `json:"timestamp"`
Recipient string `json:"recipient"`
Severity string `json:"severity"` // "permanent" or "temporary" for "failed"
Reason string `json:"reason"`
Message struct {
Headers struct {
MessageID string `json:"message-id"`
} `json:"headers"`
} `json:"message"`
Raw json.RawMessage `json:"-"`
}
MailgunEvent is the "event-data" of a Mailgun event webhook; Raw holds all of it for the fields not listed.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore keeps delivery ids in a bounded map on this node: for tests and dev. When full, the oldest id is forgotten. It does not share ids between nodes, so a retry reaching another node is handled again; production uses the db pack's table.
func Memory ¶
func Memory(max int) *MemoryStore
Memory returns a store holding at most max ids (1000 when max < 1).
func (*MemoryStore) Claim ¶
func (m *MemoryStore) Claim(_ context.Context, scope, id string, lease time.Duration) (Claim, error)
Claim implements Store.
type Option ¶
type Option func(*Endpoint)
Option configures an endpoint.
func Base64 ¶
func Base64() Option
Base64 reads an HMAC endpoint's signature as base64 instead of hex.
func ID ¶
ID takes the delivery id from f, called with the verified body; "" means the delivery has no id and is not deduplicated.
func IDHeader ¶
IDHeader takes the delivery id from a request header ("X-Delivery", "X-GitHub-Delivery"); a delivery without it is refused (400).
func Prefix ¶
Prefix is the text before the value in the header of HMAC and Token endpoints: "sha256=" (GitHub), "Bearer ". It is removed before the comparison and required.
func Retention ¶
Retention sets how long the db pack's table keeps delivery ids; default DefaultRetention.
func Timeout ¶
Timeout sets the deadline for reading the body and running the handler; default DefaultTimeout. The request's own deadline, when sooner, still applies.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
PostgresStore keeps delivery ids in the webhook_delivery table, so every node sees them. The table is created on first use, outside the app's schema: it is the framework's, not the app's data. Ids older than the retention are deleted in bounded batches, at most once an hour per node, after a delivery is recorded.
func Postgres ¶
func Postgres(pool *pgxpool.Pool, retention time.Duration) *PostgresStore
Postgres returns a store over pool that keeps ids for retention (DefaultRetention when zero or less). The endpoints use one over the db pack's pool on their own; call this to share it or set it by hand.
func (*PostgresStore) Claim ¶
func (s *PostgresStore) Claim(ctx context.Context, scope, id string, lease time.Duration) (Claim, error)
Claim implements Store.
type Store ¶
type Store interface {
// Claim marks id as being handled for lease. A claim older than its
// lease and never done (a node that died mid-handler) is claimed
// again.
Claim(ctx context.Context, scope, id string, lease time.Duration) (Claim, error)
// Done records id as handled.
Done(ctx context.Context, scope, id string) error
// Release drops an unfinished claim, so the provider's retry is
// handled.
Release(ctx context.Context, scope, id string) error
}
Store records delivery ids, so each is handled once. Scope names the endpoint ("stripe:PAYMENTS_WEBHOOK_SECRET").
type StripeEvent ¶
type StripeEvent struct {
ID string `json:"id"`
Type string `json:"type"` // "checkout.session.completed"
Created int64 `json:"created"`
Livemode bool `json:"livemode"`
Data struct {
Object json.RawMessage `json:"object"`
} `json:"data"`
}
StripeEvent is the envelope of a Stripe event; Data.Object is the object the event is about, decoded by the app into its own type.