webhook

package
v0.1.115 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

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

View Source
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.

View Source
const StandardSecretPrefix = "whsec_"

StandardSecretPrefix starts a Standard Webhooks secret: whsec_ and the key in base64.

Variables

This section is empty.

Functions

func HMACSignature

func HMACSignature(secret string, body []byte) string

HMACSignature returns the hex HMAC-SHA256 of body under secret, the value an HMAC endpoint expects after its Prefix.

func MailgunSignature

func MailgunSignature(key string, t time.Time, token string) json.RawMessage

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

func StandardKey(secret string) ([]byte, error)

StandardKey reads a Standard Webhooks secret ("whsec_<base64>") as the HMAC key; a secret without the prefix is used as it is.

func StandardSignature

func StandardSignature(key []byte, id string, ts time.Time, body []byte) string

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.

func StripeSignature

func StripeSignature(secret string, t time.Time, body []byte) string

StripeSignature returns the Stripe-Signature header for body, signed at t with secret.

Types

type Claim

type Claim int

Claim is the outcome of Store.Claim.

const (
	// Claimed: the id is new (or its earlier claim lapsed); handle it.
	Claimed Claim = iota
	// Duplicate: a delivery with this id was handled; acknowledge it
	// without handling it again.
	Duplicate
	// Busy: another request is handling this id now.
	Busy
)

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) Decode

func (d *Delivery) Decode(v any) error

Decode unmarshals the verified body as JSON into v.

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

func HMAC(setting, header string, h Handler, opts ...Option) *Endpoint

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

func Mailgun(setting string, h Handler, opts ...Option) *Endpoint

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

func Standard(setting string, h Handler, opts ...Option) *Endpoint

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

func Stripe(setting string, h Handler, opts ...Option) *Endpoint

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

func Token(setting, header string, h Handler, opts ...Option) *Endpoint

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

func TokenField(setting, field string, h Handler, opts ...Option) *Endpoint

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.

func (*Endpoint) ServeHTTP

func (e *Endpoint) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP verifies the delivery, deduplicates it and runs the handler.

type Handler

type Handler func(ctx context.Context, d *Delivery) error

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.

func (*MemoryStore) Done

func (m *MemoryStore) Done(_ context.Context, scope, id string) error

Done implements Store.

func (*MemoryStore) Release

func (m *MemoryStore) Release(_ context.Context, scope, id string) error

Release 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

func ID(f func(r *http.Request, body []byte) string) Option

ID takes the delivery id from f, called with the verified body; "" means the delivery has no id and is not deduplicated.

func IDHeader

func IDHeader(name string) Option

IDHeader takes the delivery id from a request header ("X-Delivery", "X-GitHub-Delivery"); a delivery without it is refused (400).

func MaxBody

func MaxBody(n int64) Option

MaxBody sets the body limit in bytes; default DefaultMaxBody.

func Prefix

func Prefix(p string) Option

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

func Retention(d time.Duration) Option

Retention sets how long the db pack's table keeps delivery ids; default DefaultRetention.

func Timeout

func Timeout(d time.Duration) Option

Timeout sets the deadline for reading the body and running the handler; default DefaultTimeout. The request's own deadline, when sooner, still applies.

func Tolerance

func Tolerance(d time.Duration) Option

Tolerance sets the replay window for schemes that sign a timestamp (Stripe, Mailgun); default DefaultTolerance.

func WithStore

func WithStore(s Store) Option

WithStore records delivery ids in s instead of the db pack's table.

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.

func (*PostgresStore) Done

func (s *PostgresStore) Done(ctx context.Context, scope, id string) error

Done implements Store.

func (*PostgresStore) Release

func (s *PostgresStore) Release(ctx context.Context, scope, id string) error

Release 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL