inbound

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 2026 License: AGPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package inbound receives webhooks: it verifies the provider's signature over the bytes as they arrived, publishes the delivery to a message queue, and acks.

It is the mirror of the parent webhooks package, which sends them.

The two failures it exists to remove

Verification is the first. Every provider signs differently and every consumer implements the scheme again, and being subtly wrong is silent: a comparison that is not constant-time, a timestamp with no tolerance window, an HMAC over the decoded body instead of the bytes as received. None of those fail a test written against the happy path, and all of them are the whole security of the endpoint. Verifier is the seam, and the schemes behind it are this package's.

Ack latency is the second. A handler that does its work inline couples the provider's ack deadline to how long that work takes. Providers time out in the tens of seconds and retry anything that is not 2xx, so on the afternoon the database is slow the work completes, the ack misses the window, and the same event arrives again and is processed a second time. Sustained failures get the endpoint disabled outright — Stripe and GitHub both do this — at which point every subsequent event is lost until somebody notices. A Receiver does one bounded thing before it acks, which is publish.

What it does not do

It does not parse the payload. Parsing is what couples a receiver to a provider's schema and therefore to that provider's schema versioning, and the consumer has to decode the body anyway in order to act on it. Delivery carries the bytes.

It does not dedupe. A redelivery is expected — it is how a provider recovers from a missed ack — but "already processed" is a statement about the consumer's own effects, not about receipt, and only the consumer can make it. Key an idempotency.Manager on the provider's event ID at the point the work happens; see the idempotency package.

It does not store anything. An inbound receiver has no local row to keep atomic with a downstream effect, which is the problem an outbox and an events table solve. Its whole job is moving bytes from an HTTP request into a durable place: durability is the broker's, retry is the provider's, and if the publish fails the receiver simply does not ack. It holds nothing worth losing, and holding nothing is what keeps it free of a database and of any one database's dialect.

The cost of that is real and worth stating. There is no local record to answer "what did Stripe actually send us at 3am", and no replay by event ID — for which the provider's own event log (Stripe's resend, GitHub's redelivery API) is authoritative and a local copy would only ever be a cache. An archive of raw deliveries composes on top as another consumer of the same topic; it is not a thing the ack path should be waiting on.

Poison messages

An event the consumer can never process needs somewhere to land, and once a Receiver has returned 2xx the provider is done with it. That is dead-letter behavior, it belongs to the broker, and it is configured there — an SQS redrive policy, a Pub/Sub dead-letter topic — not mediated by this package or by messagequeue, which exposes no such seam. A backend without one (Redis) has no dead-letter story to configure, and a consumer running on it owns the decision to drop or park an event it cannot handle. Choosing the broker chooses the answer, which is why this package does not offer a second one.

Using it

One Receiver per provider endpoint, holding one Publisher and one Verifier:

verifier, err := inbound.NewStripeVerifier(cfg.StripeWebhookSecret)
if err != nil {
	return err
}

receiver, err := inbound.NewReceiver(verifier, publisher,
	inbound.WithReceiverLogger(logger),
	inbound.WithReceiverTracerProvider(tracerProvider),
	inbound.WithReceiverMetricsProvider(metricsProvider),
)
if err != nil {
	return err
}

receiver.Mount(router, "/webhooks/stripe")

Mount registers a POST route through routing.Handle, which records no OpenAPI operation. That is deliberate: the request body is opaque provider JSON whose schema this package does not know and should not publish as if it did.

Headers are not authenticated

Delivery.Headers carries what arrived, because a provider puts things there a consumer needs — GitHub's X-GitHub-Delivery is the delivery ID and appears nowhere else. They are not covered by any of these signatures, which sign the body (and, for Stripe, a timestamp). A consumer must therefore treat header values as untrusted for anything security-relevant, and read what matters from the verified body. Credential headers are dropped rather than forwarded, and WithForwardedHeaders narrows the set further.

Example

Receiving GitHub webhooks: verify the signature, publish the delivery, ack. The work happens on the other end of the topic, so the ack is not waiting on it.

package main

import (
	"context"
	"fmt"

	"github.com/primandproper/primitives-go/messagequeue"
	"github.com/primandproper/primitives-go/webhooks/inbound"
)

// printingPublisher stands in for a real broker so the example has something to publish to.
type printingPublisher struct{}

func (printingPublisher) Stop() {}

func (printingPublisher) PublishAsync(ctx context.Context, data any, _ ...messagequeue.PublishOption) {
	_ = ctx
	_ = data
}

func (printingPublisher) Publish(_ context.Context, data any, _ ...messagequeue.PublishOption) error {
	delivery, ok := data.(*inbound.Delivery)
	if !ok {
		return fmt.Errorf("unexpected message %T", data)
	}

	fmt.Printf("published a %s delivery of %d bytes\n", delivery.Provider, len(delivery.Body))

	return nil
}

func main() {
	verifier, err := inbound.NewGitHubVerifier("It's a Secret to Everybody")
	if err != nil {
		panic(err)
	}

	receiver, err := inbound.NewReceiver(verifier, printingPublisher{})
	if err != nil {
		panic(err)
	}

	// In a real service: receiver.Mount(router, "/webhooks/github")
	_ = receiver

	fmt.Println("mounted a receiver for", verifier.Provider())

}
Output:
mounted a receiver for github

Index

Examples

Constants

View Source
const (
	// GitHubSignatureHeader carries GitHub's HMAC-SHA-256 over the raw body.
	//
	// The older X-Hub-Signature (SHA-1) is deliberately not read. GitHub still
	// sends it for compatibility, and accepting it would mean a receiver's
	// security is set by whichever header an attacker chooses to present.
	GitHubSignatureHeader = "X-Hub-Signature-256"

	// GitHubDeliveryHeader carries GitHub's delivery ID. It is the value a
	// consumer keys deduplication on, and it exists only in the headers — see
	// Delivery.Headers on what that does and does not prove.
	GitHubDeliveryHeader = "X-GitHub-Delivery"
)
View Source
const (
	// DigestSHA256 is HMAC-SHA-256, and the default.
	DigestSHA256 Digest = "sha256"
	// DigestSHA512 is HMAC-SHA-512.
	DigestSHA512 Digest = "sha512"

	// EncodingHex is lowercase hex, and the default. Comparison is
	// case-insensitive, so a provider that sends uppercase still verifies.
	EncodingHex Encoding = "hex"
	// EncodingBase64 is standard base64 with padding.
	EncodingBase64 Encoding = "base64"
)
View Source
const (
	// StripeSignatureHeader carries Stripe's timestamp and signatures.
	StripeSignatureHeader = "Stripe-Signature"

	// RevenueCatSignatureHeader carries RevenueCat's timestamp and signature.
	//
	// RevenueCat also offers a dashboard-configured Authorization header, which
	// is a bearer token rather than a signature: it proves the sender knew a
	// secret, and says nothing about the body it was attached to. That mode is
	// deliberately not implemented here — a verifier for it would satisfy the
	// same interface while checking something weaker, and a receiver mounting
	// one could not tell from the type that its payloads were unauthenticated.
	// Turn signing on in the same dashboard page the header is configured on.
	RevenueCatSignatureHeader = "X-RevenueCat-Webhook-Signature"
)
View Source
const DefaultMaxBodyBytes int64 = 256 << 10

DefaultMaxBodyBytes bounds how much of a request body a Receiver reads.

A webhook endpoint is public and unauthenticated until the signature checks out, and the signature cannot be checked without the body, so the bound is the only thing standing between a hostile client and an allocation of whatever size it names. 256 KiB is comfortably above what the providers send — Stripe events run in the low tens of kilobytes, GitHub's largest payloads in the low hundreds — and comfortably below what a request handler should ever hold.

View Source
const DefaultTolerance = requestsigning.DefaultTolerance

DefaultTolerance is how far a signed timestamp may sit from the verifier's clock before a delivery is rejected as stale.

It is requestsigning.DefaultTolerance. Five minutes is Stripe's own default and the customary figure generally, so the two arrived at the same number independently — which is precisely why there should not be two of them to change.

Variables

View Source
var (
	// ErrInvalidSignature indicates a delivery whose signature header is
	// missing, malformed, or does not match the body under any secret the
	// verifier holds.
	//
	// The cases are deliberately one error. Telling a caller which of them
	// applied tells a forger how close it got, and none of the distinctions are
	// actionable for an operator: every one of them means the same thing, which
	// is that the request did not prove it came from the provider.
	//
	// It is requestsigning's sentinel rather than one of this package's own.
	// "This body did not prove it came from who it claims" is one fact whether
	// the signature was minted by requestsigning or by Stripe, and a service
	// verifying both would otherwise need two errors.Is calls to ask it — which
	// is the same reason the outbound half aliases ErrNoSigningSecret.
	ErrInvalidSignature = requestsigning.ErrInvalidSignature

	// ErrStaleSignature indicates a signature whose timestamp sits outside the
	// tolerance window. Only schemes that sign a timestamp can report it.
	//
	// It is separate from ErrInvalidSignature because it is the one
	// verification failure with a benign cause an operator can act on — clock
	// skew — and because it says nothing about whether the secret was right.
	// It is requestsigning's, for the reason above.
	ErrStaleSignature = requestsigning.ErrStaleSignature

	// ErrNoSecret indicates a verifier constructed without one. A verifier with
	// no secret rejects every delivery, which from the outside is
	// indistinguishable from a provider that has the wrong secret configured,
	// so it is refused at construction instead.
	ErrNoSecret = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "no webhook signing secret")

	// ErrNilVerifier indicates NewReceiver called without one. There is no
	// default: a receiver that published unverified bodies would be a public
	// endpoint for injecting messages onto an internal topic.
	ErrNilVerifier = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webhook verifier")

	// ErrNilPublisher indicates NewReceiver called without one. Publishing is
	// the entire reason the ack is fast, so there is nothing sensible to
	// substitute; a caller that genuinely wants deliveries discarded builds a
	// noop publisher and names it.
	ErrNilPublisher = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webhook publisher")

	// ErrBodyTooLarge indicates a request body exceeding the receiver's cap. It
	// is answered with 413 rather than 400, so an operator reading provider-side
	// delivery logs sees a size problem rather than a signature problem.
	ErrBodyTooLarge = platformerrors.New("webhook body exceeds the configured limit")
)

Functions

This section is empty.

Types

type Delivery

type Delivery struct {
	// ReceivedAt is when the receiver read the request, from its clock.
	// It is the receiver's own observation, not a value from the provider,
	// so a consumer can measure queue lag against it.
	ReceivedAt time.Time `json:"receivedAt"`

	// Headers carries the request's headers, minus credential headers and
	// minus anything WithForwardedHeaders excluded. They are here because
	// providers put things in them a consumer needs — GitHub's delivery ID
	// travels in X-GitHub-Delivery and nowhere else.
	//
	// They are NOT authenticated: these schemes sign the body, not the
	// headers. Treat them as untrusted metadata and take anything that
	// matters from Body.
	Headers http.Header `json:"headers,omitempty"`

	// Provider is the verifier's Provider, so a consumer reading one topic
	// carrying several providers can tell them apart.
	Provider string `json:"provider"`

	// Body is the verified payload, byte for byte as received.
	Body []byte `json:"body"`
}

Delivery is the message a Receiver publishes for a verified webhook. It is the package's wire contract with its consumers, so its JSON field names are as much a part of the API as its Go ones.

Body is the raw provider payload, exactly as it arrived and exactly as it was verified. It is []byte rather than a decoded structure because the receiver does not know the provider's schema, and rather than a string because a MAC is over bytes: round-tripping the payload through anything that could normalize it would leave a consumer unable to re-verify what it was handed.

Example

The consumer's half. It decodes the payload, keys deduplication on the provider's own event ID, and does the work — none of which the receiver knows anything about.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/primandproper/primitives-go/webhooks/inbound"
)

func main() {
	delivery := &inbound.Delivery{
		Provider: "github",
		Body:     []byte(`{"action":"opened"}`),
	}

	var payload struct {
		Action string `json:"action"`
	}
	if err := json.Unmarshal(delivery.Body, &payload); err != nil {
		panic(err)
	}

	// The delivery ID is in the headers, which the signature does not cover — fine as a
	// deduplication key, not as an authorization decision.
	fmt.Printf("%s: %s\n", delivery.Provider, payload.Action)

}
Output:
github: opened

type Digest

type Digest string

Digest names the hash an HMACScheme is computed with.

type Encoding

type Encoding string

Encoding names how an HMACScheme renders the MAC as text.

type HMACScheme

type HMACScheme struct {
	// Provider is the label the verifier reports and the receiver stamps on
	// every Delivery. Required.
	Provider string

	// Header names the request header carrying the MAC. Required. Lookup is
	// case-insensitive, as HTTP header lookup always is.
	Header string

	// Prefix is the algorithm label the provider writes ahead of the encoded
	// MAC, e.g. "sha256=". Empty when the header carries the MAC alone. A
	// header that does not begin with a non-empty Prefix is rejected, so a
	// provider that changes its algorithm label cannot have the new one
	// silently accepted under the old key.
	Prefix string

	// Digest selects the hash. Empty means DigestSHA256.
	Digest Digest

	// Encoding selects how the MAC is rendered as text. Empty means
	// EncodingHex.
	Encoding Encoding
}

HMACScheme describes a provider that signs the raw request body with an HMAC and sends the result in a single header. It covers most of the long tail: Shopify, Twilio's older scheme, Slack's inner shell, and whatever the next vendor ships.

It is a struct rather than four positional arguments because three of the four are strings, and a call site that reads NewHMACVerifier("acme", "X-Acme-Signature", secret, "sha256=") is one transposition away from verifying nothing while looking correct.

It does not cover a scheme that signs anything other than the body — an AWS SNS canonical string, or the timestamp-prefixed payload Stripe and RevenueCat both sign. The latter is TimestampedHMACScheme, which this package also ships; the rest are their own Verifier implementations.

type HMACVerifier

type HMACVerifier struct {
	// contains filtered or unexported fields
}

HMACVerifier verifies a single-header HMAC over the raw body.

func NewGitHubVerifier

func NewGitHubVerifier(secret string, opts ...VerifierOption) (*HMACVerifier, error)

NewGitHubVerifier builds a Verifier for GitHub's X-Hub-Signature-256, which is an HMAC-SHA-256 over the raw body, hex-encoded, prefixed "sha256=".

The secret is the webhook secret configured on the repository, organization, or app. Reads WithAdditionalSecrets.

func NewHMACVerifier

func NewHMACVerifier(scheme *HMACScheme, secret string, opts ...VerifierOption) (*HMACVerifier, error)

NewHMACVerifier builds a Verifier for a provider that signs the raw body under scheme.

Reads WithAdditionalSecrets. The timestamp options do nothing here: a scheme with no signed timestamp has no freshness to check, and pretending otherwise by reading some unsigned header would check a value an attacker can edit.

func (*HMACVerifier) Provider

func (v *HMACVerifier) Provider() string

Provider returns the scheme's provider label.

func (*HMACVerifier) Verify

func (v *HMACVerifier) Verify(_ context.Context, headers http.Header, body []byte) error

Verify checks the scheme's header against body.

type Receiver

type Receiver struct {
	// contains filtered or unexported fields
}

Receiver is the HTTP half of inbound webhook handling: it reads a bounded body, verifies it, publishes it, and acks.

One Receiver serves one provider endpoint, because it holds one Verifier (which knows one scheme and one set of secrets) and one Publisher (which is bound to one topic). A service taking webhooks from Stripe and GitHub builds two and mounts them at two paths.

It is a concrete type rather than an interface. There is no second implementation to swap in: the seams that vary are Verifier and messagequeue.Publisher, and both are already interfaces this takes.

func NewReceiver

func NewReceiver(verifier Verifier, publisher messagequeue.Publisher, opts ...ReceiverOption) (*Receiver, error)

NewReceiver builds a Receiver.

verifier and publisher are parameters rather than options because neither has a safe default. A receiver with no verifier is a public endpoint for injecting messages onto an internal topic; a receiver with no publisher acks deliveries and drops them, which is the failure this package exists to make impossible.

func (*Receiver) Mount

func (r *Receiver) Mount(router *routing.Router, pattern string, middleware ...routing.Middleware)

Mount registers the receiver as a POST route on router.

It goes through routing.Handle, the untyped escape hatch, so no OpenAPI operation is recorded. That is deliberate rather than an omission: the request body is opaque provider JSON whose schema this package does not know, and a spec claiming otherwise would be documenting a shape nothing enforces.

POST only. Every provider here posts, and accepting other methods would widen a public endpoint for no caller.

func (*Receiver) ServeHTTP

func (r *Receiver) ServeHTTP(res http.ResponseWriter, req *http.Request)

ServeHTTP reads, verifies, publishes, and acks — in that order, and with nothing else between the request and the response.

The status codes are chosen for what the provider does with them, which is retry anything that is not 2xx:

  • 204 once the delivery is durably published. There is no body, because nothing reads it and an error string on a public endpoint is a probing surface.
  • 400 for a signature that did not check out, and for a body that could not be read. Neither improves on a retry.
  • 413 for a body over the cap, so provider-side delivery logs show a size problem rather than a signature problem.
  • 503 for a failed publish. This is the case the design turns on: the receiver has not acked, so the delivery is still the provider's, and its retry is what covers the outage.

type ReceiverOption

type ReceiverOption func(*Receiver)

ReceiverOption configures a Receiver.

The observability dependencies are options rather than parameters because each is genuinely optional: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing.

func WithForwardedHeaders

func WithForwardedHeaders(names ...string) ReceiverOption

WithForwardedHeaders narrows Delivery.Headers to the named headers, dropping everything else. Names are matched case-insensitively, as HTTP header lookup always is; an empty call is ignored.

The default forwards what arrived, minus credential headers, because a consumer generally needs a provider-specific header it did not think to name — GitHub's delivery ID, Shopify's shop domain, the provider's API version. Narrow it when a topic's messages are retained somewhere their size or their contents matter, and remember that none of these values are authenticated by any of these schemes.

func WithMaxBodyBytes

func WithMaxBodyBytes(n int64) ReceiverOption

WithMaxBodyBytes overrides DefaultMaxBodyBytes — how much of a request body the receiver will read before answering 413. A non-positive value leaves the default in place, because an unbounded read on a public endpoint is not a configuration this package offers.

Raise it for a provider that sends genuinely large payloads. Lowering it below what the provider sends turns every delivery into a 413, which the provider retries and eventually gives up on.

func WithReceiverClock

func WithReceiverClock(c clock.Clock) ReceiverOption

WithReceiverClock swaps the source of time stamped onto Delivery.ReceivedAt. A nil clock is ignored.

func WithReceiverLogger

func WithReceiverLogger(logger logging.Logger) ReceiverOption

WithReceiverLogger attaches a logger.

func WithReceiverMetricsProvider

func WithReceiverMetricsProvider(metricsProvider metrics.Provider) ReceiverOption

WithReceiverMetricsProvider attaches a metrics provider. An absent provider records nothing.

func WithReceiverTracerProvider

func WithReceiverTracerProvider(tracerProvider tracing.Provider) ReceiverOption

WithReceiverTracerProvider attaches a tracer provider, enabling a span per received delivery.

type TimestampedHMACScheme

type TimestampedHMACScheme struct {
	// Provider is the label the verifier reports and the receiver stamps on
	// every Delivery. Required.
	Provider string

	// Header names the request header carrying the timestamp and the MAC.
	// Required. Lookup is case-insensitive, as HTTP header lookup always is.
	Header string
}

TimestampedHMACScheme describes a provider that signs a timestamp and the raw body together — "<timestamp>.<body>" — with HMAC-SHA-256, and sends both the timestamp and the hex MAC as elements of a single header.

Stripe published the shape and RevenueCat adopted it verbatim, down to the element keys; only the header name differs between them. That is why this is a scheme with a header rather than two verifiers: the parse, the ordering of the freshness check against the HMAC work, and the exact bytes that get signed are all things a second copy could get subtly wrong, and being wrong here is silent.

A provider that signs the body alone is the other shape, and is HMACScheme.

type TimestampedHMACVerifier

type TimestampedHMACVerifier struct {
	// contains filtered or unexported fields
}

TimestampedHMACVerifier verifies a t=…,v1=… header against the timestamp and body it signs.

func NewRevenueCatVerifier

func NewRevenueCatVerifier(secret string, opts ...VerifierOption) (*TimestampedHMACVerifier, error)

NewRevenueCatVerifier builds a Verifier for RevenueCat's X-RevenueCat-Webhook-Signature header.

secret is the signing secret shown on the webhook integration in RevenueCat's dashboard, which is a different value from the Authorization header configured on the same page — see RevenueCatSignatureHeader on why only the signed scheme is implemented. Reads WithAdditionalSecrets, WithTolerance, WithClock, and WithVerificationTime.

RevenueCat re-signs on every delivery attempt, so the timestamp is when that particular request was signed rather than when the event happened. A retry of an event from an hour ago therefore arrives inside the tolerance window, which is the behavior the freshness check wants: it bounds how long a captured request stays replayable, not how old an event may be.

func NewStripeVerifier

func NewStripeVerifier(secret string, opts ...VerifierOption) (*TimestampedHMACVerifier, error)

NewStripeVerifier builds a Verifier for Stripe's Stripe-Signature header.

secret is the endpoint's signing secret, the "whsec_…" value. Reads WithAdditionalSecrets, WithTolerance, WithClock, and WithVerificationTime.

func NewTimestampedHMACVerifier

func NewTimestampedHMACVerifier(scheme *TimestampedHMACScheme, secret string, opts ...VerifierOption) (*TimestampedHMACVerifier, error)

NewTimestampedHMACVerifier builds a Verifier for a provider that signs "<timestamp>.<body>" under scheme.

Signing the timestamp alongside the body is what makes the freshness check meaningful: the value compared against the clock is inside the signed material, so an attacker replaying a captured delivery cannot move it.

Reads WithAdditionalSecrets, WithTolerance, WithClock, and WithVerificationTime.

A header may carry several v1 elements and any one of them matching is enough. Stripe emits one per active endpoint secret during its own secret rollover, so rejecting on the first mismatch would fail every delivery for the length of a rotation the receiver has no say in.

func (*TimestampedHMACVerifier) Provider

func (v *TimestampedHMACVerifier) Provider() string

Provider returns the scheme's provider label.

func (*TimestampedHMACVerifier) Verify

func (v *TimestampedHMACVerifier) Verify(_ context.Context, headers http.Header, body []byte) error

Verify checks the scheme's header against body.

The staleness check runs before any HMAC work, so a flood of replayed deliveries costs a parse rather than a hash per key. It runs on the timestamp as presented, which is unauthenticated at that point — but a forged timestamp only ever moves a delivery out of the window or leaves it signed under a payload whose MAC will not match, so nothing is decided on an unverified value.

type Verifier

type Verifier interface {
	// Provider names the provider this verifier speaks for, e.g. "stripe".
	// It is a label: it lands on Delivery, on spans, and on this package's
	// metrics, and nothing dispatches on it.
	Provider() string

	// Verify returns nil only if body was signed under a secret this
	// verifier holds. It returns ErrInvalidSignature for anything that did
	// not prove that, and ErrStaleSignature when the scheme carries a
	// timestamp and the timestamp is outside tolerance.
	Verify(ctx context.Context, headers http.Header, body []byte) error
}

Verifier decides whether a delivery really came from the provider.

It takes the header bag and the body separately rather than an *http.Request, which is what makes it usable at all: by the time a Receiver verifies, it has already read the body under a cap, and a request handed to a verifier at that point has an empty Body and no GetBody to rewind. Passing the bytes explicitly also means the bytes verified are provably the bytes published — there is no second read that could see something different — and it lets a consumer re-verify a payload it took off a queue, where there is no request at all.

body must be the bytes exactly as received. Decoding, re-encoding, or pretty-printing JSON produces a different byte sequence and therefore a different MAC, and the resulting failure looks like a wrong secret.

The implementations here are Stripe, GitHub, and a configurable HMAC for the long tail; a scheme this package does not implement satisfies the same two methods and runs through the same Receiver.

type VerifierOption

type VerifierOption func(*verifierConfig)

VerifierOption configures a Verifier this package builds. One type serves every scheme, because they share a notion of what secrets are held and what time it is; each option's doc says which constructors read it.

func WithAdditionalSecrets

func WithAdditionalSecrets(secrets ...string) VerifierOption

WithAdditionalSecrets adds secrets a delivery may also be signed under, so that rotating a webhook secret is not an outage. Read by every verifier constructor. Empty entries are ignored.

A rotation has a window in which the provider may be signing with either value — the receiver cannot make both sides switch at the same instant, and a provider's console generally shows the new secret before it starts using it. Holding both through that window is what makes the rotation survivable; the old one is dropped from configuration once deliveries have moved.

This is the receiver's own rotation. Stripe's endpoint-secret rollover shows up differently, as several v1 elements in one header, and is handled without any configuration at all.

func WithClock

func WithClock(c clock.Clock) VerifierOption

WithClock swaps the source of time a verifier compares a signed timestamp against. Read by the timestamped-HMAC verifiers. A nil clock is ignored.

Inside a testing/synctest bubble clock.NewClock already reads the bubble's fake time, so this is for what a bubble cannot express — a deliberately skewed peer, a clock driven by a harness.

func WithTolerance

func WithTolerance(d time.Duration) VerifierOption

WithTolerance overrides DefaultTolerance — how far a signed timestamp may sit from the verifier's clock. A non-positive duration leaves the default in place. Read by the timestamped-HMAC verifiers — NewStripeVerifier and NewRevenueCatVerifier; schemes with no signed timestamp ignore it.

There is deliberately no way to disable the check. A signature with no freshness bound is replayable forever, which is the property a signed timestamp exists to remove.

func WithVerificationTime

func WithVerificationTime(t time.Time) VerifierOption

WithVerificationTime pins the instant a verification compares a signed timestamp against, instead of reading a clock. It wins over WithClock, and exists for tests and for replaying a captured delivery against a known instant. Read by the timestamped-HMAC verifiers.

A zero time is ignored, so this cannot accidentally pin verification to the Unix epoch and reject everything.

Directories

Path Synopsis
Package inboundcfg assembles an inbound webhook receiver from environment configuration: the Verifier for the provider's signing scheme, and the Receiver that mounts on a router and publishes what it verifies.
Package inboundcfg assembles an inbound webhook receiver from environment configuration: the Verifier for the provider's signing scheme, and the Receiver that mounts on a router and publishes what it verifies.

Jump to

Keyboard shortcuts

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