webhook

package module
v0.1.0 Latest Latest
Warning

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

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

README

github.com/ubgo/auth/plugin/webhook

Sends signed HTTP POSTs to other services when something happens in auth: a user signs up, changes or is deleted, a session starts or ends, an identity is added, verified or removed, a security event fires, or your own code publishes an event. Requests follow the Standard Webhooks spec (webhook-id, webhook-timestamp, webhook-signature, HMAC-SHA256 under a whsec_ secret), so any Standard Webhooks library verifies them. The guide is docs/plugins/webhook.md; the runnable example is examples/webhooks.

Install

go get github.com/ubgo/auth/plugin/webhook

A separate module because sending webhooks is not authentication: an app that sends none never compiles it. It needs the core module and github.com/ubgo/sqlb only.

What it guarantees

  • Never inside the request. An event writes one delivery row per subscribed endpoint in the transaction that produced it; the first send runs after the commit, in the background. A transaction that rolls back sends nothing.
  • Retries and dead letters. A failed send is retried by auth maintain with an exponential backoff and jitter; after MaxAttempts (default 10) the delivery is dead-lettered, listable with Deliveries and sendable again with Replay. A 410 Gone disables the endpoint.
  • Secrets sealed at rest through auth.Sealer (or a key derived from auth.WithSecret); no Endpoint value, log line or stored error carries one.
  • An SSRF guard: https only, and the address actually dialled, after DNS, must be public; redirects are never followed. AllowPrivateNetworks() is the one escape hatch, for local development.
  • No HTTP routes. Endpoints are managed from Go (CreateEndpoint, UpdateEndpoint, DeleteEndpoint, RotateSecret, EndpointSecret), so your own admin screen and authorization decide who may register a URL.

Verify on the receiving side

The signature package imports only the standard library, so a Go receiver needs nothing else:

body, _ := io.ReadAll(r.Body)
if err := signature.Verify(secret, r.Header, body); err != nil {
	http.Error(w, "bad signature", http.StatusUnauthorized)
	return
}

Verify compares every candidate signature in constant time and refuses a timestamp more than five minutes from the receiver's clock. Delivery is at least once: de-duplicate on the webhook-id header.

Documentation

Overview

Package webhook sends signed HTTP POSTs to other services when something happens in the auth library: a user signed up, changed or was deleted, a session started or ended, an identity was added, verified or removed, a security event fired, or anything an app or another plugin publishes (Publish). It is Clerk's and Svix's outgoing webhooks, embedded.

Requests follow the Standard Webhooks spec (https://www.standardwebhooks.com, package signature): webhook-id, webhook-timestamp and webhook-signature headers, HMAC-SHA256 under a "whsec_" secret, a JSON body of type, timestamp and data. A receiver in any language verifies them with a Standard Webhooks library; a Go receiver can use signature.Verify.

Delivery never happens inside the request. An event writes one delivery row per subscribed endpoint in the transaction that produced it, so a sign-up that rolls back sends nothing and one that commits always has its rows; the first send runs after the commit in the background (auth.RunInBackground). A failed send is retried by Maintain ("auth maintain", or the app's scheduler) with an exponential backoff, and a delivery that used every attempt is dead-lettered: kept, never sent again, listable (Deliveries) and replayable (Replay). Delivery is at least once, as every webhook system's is: a receiver de-duplicates on webhook-id.

It is not authentication, so it is its own module.

Index

Constants

View Source
const (
	DisabledManual = "manual"
	DisabledGone   = "gone"
)

Values of Endpoint.DisabledReason.

View Source
const (
	TableNameEndpoint = "webhook_endpoint"
	TableNameDelivery = "webhook_delivery"

	ColID         = "id"
	ColURL        = "url"
	ColSecret     = "secret"
	ColPrevSecret = "previous_secret"
	ColPrevUntil  = "previous_until"
	ColEvents     = "events"
	ColDesc       = "description"
	ColEnabled    = "enabled"
	ColDisabledBy = "disabled_reason"
	ColCreatedAt  = "created_at"
	ColUpdatedAt  = "updated_at"

	ColMessageID   = "message_id"
	ColEndpointID  = "endpoint_id"
	ColEventType   = "event_type"
	ColPayload     = "payload"
	ColStatus      = "status"
	ColAttempts    = "attempts"
	ColNextAttempt = "next_attempt_at"
	ColLastStatus  = "last_status"
	ColLastError   = "last_error"
	ColFinishedAt  = "finished_at"

	// IDPrefixEndpoint and IDPrefixDelivery start the row ids.
	IDPrefixEndpoint = "whe"
	IDPrefixDelivery = "whd"
)

Table and column names, prefixed with the plugin name.

View Source
const (
	// DefaultTimeout bounds one POST, connection to last response byte. The
	// spec recommends 15 to 30 seconds; the low end keeps a slow receiver
	// from holding a worker, and it must stay under auth.DeliveryTimeout,
	// which bounds the post-commit send it runs in.
	//ds:def id=defaulttimeout-ksebt9na
	DefaultTimeout = 15 * time.Second
	// DefaultMaxAttempts is how many sends one delivery gets, the first
	// included, before it is dead-lettered. With DefaultRetryBase doubling
	// up to DefaultRetryMax the last one is about eight and a half hours
	// after the first, provided Maintain runs that often.
	//ds:def id=defaultmaxattempts-z3n2xy52
	DefaultMaxAttempts = 10
	// DefaultRetryBase is the wait after the first failed send; each later
	// failure doubles it.
	//ds:def id=defaultretrybase-kxbdpd2u
	DefaultRetryBase = time.Minute
	// DefaultRetryMax caps the wait between two sends.
	DefaultRetryMax = 6 * time.Hour
	// DefaultRetention is how long a finished delivery (delivered, dead or
	// cancelled) is kept for Deliveries and Replay before Sweep deletes it.
	//ds:def id=defaultretention-5dyjp3z8
	DefaultRetention = 30 * 24 * time.Hour
	// DefaultBatch is the most due deliveries one Maintain run sends, so a
	// backlog is worked through by repeated runs rather than one run that
	// outlives its scheduler's timeout.
	DefaultBatch = 100
	// DefaultConcurrency is how many sends one Maintain run makes at once:
	// a backlog of slow receivers drains in parallel without one run
	// opening a hundred connections.
	DefaultConcurrency = 8
	// MaxEndpoints bounds the registry. Every event reads every enabled
	// endpoint inside the writing transaction and writes a row per match,
	// so the bound is also the worst fan-out of one sign-up.
	//ds:def id=maxendpoints-reqavdvf
	MaxEndpoints = 50
	// MaxURLLength fits a URL in the column on every engine with room to
	// spare; a longer one is a mistake, not a webhook.
	MaxURLLength = 2048
	// MaxDescriptionLength bounds an endpoint's free-text label.
	MaxDescriptionLength = 500
	// MaxEventFilters bounds an endpoint's event filter list.
	MaxEventFilters = 100
	// DefaultRotationGrace is how long RotateSecret keeps signing with the
	// previous secret as well, so a receiver has a day to switch.
	DefaultRotationGrace = 24 * time.Hour
	// DefaultPageSize and MaxPageSize shape Deliveries.
	DefaultPageSize = 50
	MaxPageSize     = 200
)

Defaults and limits.

View Source
const PluginName = "webhook"

PluginName is the plugin's permanent identity and its migration owner.

Variables

View Source
var (
	TableEndpoint = sqlb.Table{Name: TableNameEndpoint, Alias: "whe"}
	TableDelivery = sqlb.Table{Name: TableNameDelivery, Alias: "whd"}
)

Tables, for an app's own queries.

View Source
var (
	ErrEndpointNotFound   = auth.NewError("webhook.endpoint_not_found", http.StatusNotFound, "The webhook endpoint does not exist.")
	ErrDeliveryNotFound   = auth.NewError("webhook.delivery_not_found", http.StatusNotFound, "The webhook delivery does not exist.")
	ErrBadURL             = auth.NewError("webhook.bad_url", http.StatusBadRequest, "The webhook URL must be an absolute https URL without credentials.")
	ErrDestinationRefused = auth.NewError("webhook.destination_refused", http.StatusBadRequest, "The webhook URL points at a private or local address.")
	ErrBadEventType       = auth.NewError("webhook.bad_event_type", http.StatusBadRequest, "The event type or filter is not valid.")
	ErrBadSecret          = auth.NewError("webhook.bad_secret", http.StatusBadRequest, "The webhook secret must be whsec_ and base64 of 24 to 64 bytes.")
	ErrBadDescription     = auth.NewError("webhook.bad_description", http.StatusBadRequest, "The webhook description is too long.")
	ErrTooManyEndpoints   = auth.NewError("webhook.too_many_endpoints", http.StatusConflict, "There are too many webhook endpoints.")
	ErrEndpointDisabled   = auth.NewError("webhook.endpoint_disabled", http.StatusConflict, "The webhook endpoint is disabled.")
	// ErrNoEncryptionKey: neither WithSealer, auth.WithSealer nor
	// auth.WithSecret gives a way to seal endpoint secrets.
	ErrNoEncryptionKey = auth.NewError("webhook.no_encryption_key", http.StatusInternalServerError, "Webhook secrets cannot be sealed.")
)

Errors. Every one is an *auth.Error, so an admin screen built on the Go API can hand it to a transport as it stands.

EventTypeValues lists the fixed types the plugin emits, for an admin screen that offers them as filters. Security events add SecurityEventPrefix plus each auth.SecurityKindValues entry.

StatusValues lists every status, for a filter on an admin screen.

Functions

This section is empty.

Types

type Delivery

type Delivery struct {
	ID string `json:"id"`
	// MessageID is the webhook-id header: the same for every endpoint the
	// event went to, every retry and every Replay, so a receiver
	// de-duplicates on it.
	MessageID  string          `json:"messageId"`
	EndpointID string          `json:"endpointId"`
	EventType  EventType       `json:"eventType"`
	Payload    json.RawMessage `json:"payload"`
	Status     Status          `json:"status"`
	// Attempts is how many sends were started.
	Attempts int `json:"attempts"`
	// NextAttemptAt is when a pending delivery is due.
	NextAttemptAt time.Time `json:"nextAttemptAt"`
	// LastStatus is the HTTP status of the last answer, 0 when there was
	// none (a refused connection, a timeout).
	LastStatus int `json:"lastStatus"`
	// LastError says why the last send failed: the HTTP status, or the
	// network error without the URL. Never a secret or the body.
	LastError  string     `json:"lastError"`
	CreatedAt  time.Time  `json:"createdAt"`
	FinishedAt *time.Time `json:"finishedAt,omitempty"`
}

Delivery is one message to one endpoint and how its sends went.

type DeliveryPage

type DeliveryPage struct {
	Deliveries []Delivery `json:"deliveries"`
	Total      int64      `json:"total"`
	Page       int        `json:"page"`
	Limit      int        `json:"limit"`
}

DeliveryPage is one page of deliveries, newest first.

type DeliveryQuery

type DeliveryQuery struct {
	EndpointID string
	MessageID  string
	EventType  EventType
	Status     Status
	Page       int
	Limit      int
}

DeliveryQuery narrows Deliveries. Every field is optional; Page starts at 1, Limit defaults to auth.WithDefaultPageSize, else DefaultPageSize, and is clamped to MaxPageSize.

type Endpoint

type Endpoint struct {
	ID  string `json:"id"`
	URL string `json:"url"`
	// Events are the filters: a type ("user.created"), a prefix
	// ("user.*"), or "*"; empty means every event.
	Events      []string `json:"events"`
	Description string   `json:"description"`
	Enabled     bool     `json:"enabled"`
	// DisabledReason says why Enabled is false: DisabledManual (an update
	// turned it off) or DisabledGone (the receiver answered 410 Gone, which
	// the spec says means "stop sending").
	DisabledReason string    `json:"disabledReason,omitempty"`
	CreatedAt      time.Time `json:"createdAt"`
	UpdatedAt      time.Time `json:"updatedAt"`
}

Endpoint is a registered receiver. It never carries the secret, so an Endpoint can be logged or returned to a screen as it stands; EndpointSecret reads the secret on purpose.

type EndpointInput

type EndpointInput struct {
	URL         string
	Events      []string
	Description string
	// Secret is an existing "whsec_" secret to sign with, for an endpoint
	// moved from another sender; empty makes a new one.
	Secret string
}

EndpointInput registers an endpoint.

type EndpointUpdate

type EndpointUpdate struct {
	URL         *string
	Events      *[]string
	Description *string
	Enabled     *bool
}

EndpointUpdate changes an endpoint; a nil field is left as it is. Enabled true also clears DisabledReason.

type Envelope

type Envelope struct {
	Type EventType `json:"type"`
	// Timestamp is when the event happened, not when this attempt was sent
	// (that is the webhook-timestamp header).
	Timestamp time.Time `json:"timestamp"`
	Data      any       `json:"data"`
}

Envelope is the JSON body of every webhook, the spec's payload structure.

type EventType

type EventType = string

EventType names what a webhook reports, full-stop delimited as the spec asks. The constants are what the plugin emits on its own; Publish sends any other valid type, and security events are SecurityEventPrefix plus the auth.SecurityKind.

const (
	// EventUserCreated: a user row was created (sign-up, import, an admin).
	// Data is UserData.
	EventUserCreated EventType = "user.created"
	// EventUserUpdated: the user's profile (name, image) changed. Data is
	// UserData, the new values.
	EventUserUpdated EventType = "user.updated"
	// EventUserDeleted: the user is being deleted; sent only if the
	// deletion commits. Data is UserData as it was.
	EventUserDeleted EventType = "user.deleted"
	// EventSessionCreated: a session that signs someone in was created.
	// A session still waiting for a second factor is not reported; the
	// full session that replaces it is. Data is SessionData.
	EventSessionCreated EventType = "session.created"
	// EventSessionRevoked: a session ended before the user let it expire, for
	// any reason (sign-out, revoke, an admin, a ban, a password change,
	// token-reuse theft, user deletion) or was removed by the expiry sweep.
	// Data is SessionRevokedData: SessionData plus the reason.
	EventSessionRevoked EventType = "session.revoked"
	// EventIdentityCreated, EventIdentityUpdated and EventIdentityDeleted:
	// an email address, phone number or username was added, changed
	// (verified, made primary) or removed. Data is IdentityData.
	EventIdentityCreated EventType = "identity.created"
	EventIdentityUpdated EventType = "identity.updated"
	EventIdentityDeleted EventType = "identity.deleted"
	// SecurityEventPrefix starts the type of every security event the
	// plugin's SecuritySink forwards: "security.rate.limit",
	// "security.credential.stuffing", … Data is SecurityData.
	SecurityEventPrefix = "security."
)

Event types the plugin emits.

type IdentityData

type IdentityData struct {
	ID         string     `json:"id"`
	UserID     string     `json:"userId"`
	Kind       string     `json:"kind"`
	Value      string     `json:"value"`
	Verified   bool       `json:"verified"`
	VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
	IsPrimary  bool       `json:"isPrimary"`
}

IdentityData is the data of the identity.* events.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func AllowPrivateNetworks

func AllowPrivateNetworks() Option

AllowPrivateNetworks lets endpoints point at loopback, private, link-local and other non-public addresses, and use plain http. It is for local development and tests only: in production a webhook URL is chosen by whoever can register an endpoint, and without the guard it reaches the app's own network (a metadata service, an admin port, a database) from inside it. The guard checks the address actually dialled, after DNS, so a public name that resolves to a private address is refused too.

func Retention

func Retention(d time.Duration) Option

Retention sets how long a finished delivery is kept; zero or negative keeps DefaultRetention.

func Retry

func Retry(r RetryPolicy) Option

Retry sets the retry schedule; see RetryPolicy.

func Timeout

func Timeout(d time.Duration) Option

Timeout bounds one POST. It must be positive and at most auth.DeliveryTimeout, which bounds the post-commit send it runs in.

func WithSealer

func WithSealer(s auth.Sealer) Option

WithSealer seals endpoint secrets with s instead of auth.WithSealer's Sealer or a key derived from auth.WithSecret.

type Plugin

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

Plugin is the webhook sender. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) CreateEndpoint

func (p *Plugin) CreateEndpoint(ctx context.Context, in EndpointInput) (Endpoint, string, error)

CreateEndpoint registers an endpoint and returns it with its secret, which the receiver needs to verify signatures. It joins the transaction in ctx.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks turn core writes into events. Each runs inside the writing transaction, so its delivery rows commit or roll back with the write.

func (*Plugin) DeleteEndpoint

func (p *Plugin) DeleteEndpoint(ctx context.Context, id string) error

DeleteEndpoint removes an endpoint and cancels its pending deliveries. Its finished deliveries stay until Sweep, so its history can still be read.

func (*Plugin) Deliveries

func (p *Plugin) Deliveries(ctx context.Context, q DeliveryQuery) (DeliveryPage, error)

Deliveries lists deliveries, newest first: an endpoint's history, a message's fan-out, or every dead letter (Status: StatusDead). No authorization of its own; the app decides who sees it.

func (*Plugin) Delivery

func (p *Plugin) Delivery(ctx context.Context, id string) (Delivery, error)

Delivery returns one delivery, or ErrDeliveryNotFound.

func (*Plugin) Endpoint

func (p *Plugin) Endpoint(ctx context.Context, id string) (Endpoint, error)

Endpoint returns one endpoint, or ErrEndpointNotFound.

func (*Plugin) EndpointSecret

func (p *Plugin) EndpointSecret(ctx context.Context, id string) (string, error)

EndpointSecret returns the endpoint's current secret, for an admin screen that shows it again. It is the only read of a secret; nothing else returns or logs one.

func (*Plugin) Endpoints

func (p *Plugin) Endpoints(ctx context.Context) ([]Endpoint, error)

Endpoints lists every endpoint, oldest first. The registry is bounded by MaxEndpoints, so it is one page.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core and settles the sealer: WithSealer, else auth.WithSealer, else a key ring derived from auth.WithSecret. Endpoints and deliveries live in the database, so a database is required.

func (*Plugin) Maintain

func (p *Plugin) Maintain(ctx context.Context, o auth.MaintainOptions) (map[string]int64, error)

Maintain is auth.Maintainer: it sends every pending delivery that is due, up to DefaultBatch per run, DefaultConcurrency at a time, and counts the outcomes (delivered, retrying, dead, cancelled, skipped). A dry run counts what is due ("due") and sends nothing. Run it as often as the retry schedule should be honoured: every minute keeps DefaultRetryBase accurate.

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; auth.New refuses to start while it is not nil.

func (*Plugin) Publish

func (p *Plugin) Publish(ctx context.Context, eventType EventType, data any) error

Publish sends an event of the app's own, or another plugin's, to every endpoint subscribed to eventType: an organization hook, a billing change, anything. It joins the transaction in ctx (auth.Tx), so the event is sent only if that transaction commits; outside one it is written at once. data is marshalled to JSON now. eventType must be lower-case words joined by dots ("org.member.added").

func (*Plugin) Replay

func (p *Plugin) Replay(ctx context.Context, id string) (Delivery, error)

Replay sends a delivery again as a new delivery to the same endpoint, with the same webhook-id and body: a dead letter after the receiver was fixed, or a delivered one the receiver lost. The original row is left as it was, so the history stays true. The endpoint must still exist and be enabled. The first send runs after the commit, as for any event.

func (*Plugin) RotateSecret

func (p *Plugin) RotateSecret(ctx context.Context, id string, grace time.Duration) (string, error)

RotateSecret gives the endpoint a new secret and returns it. For grace (DefaultRotationGrace when 0 or negative) every send carries two signatures, the new secret's and the old one's, so the receiver can switch at any point in that window without a failed delivery.

func (*Plugin) SecuritySink

func (p *Plugin) SecuritySink() auth.SecuritySink

SecuritySink returns the sink that forwards the security event stream as security.* webhooks. Register it with auth.WithSecuritySink:

hooks := webhook.New()
a, err := auth.New(auth.WithPlugins(hooks, ...),
	auth.WithSecuritySink(hooks.SecuritySink(), auth.SecurityQueue{Name: "webhook"}))

The stream is not durable and may drop events under pressure (see auth.WithSecuritySink); once an event reaches this sink its deliveries are written to the table and retried like any other.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, _ time.Time) (int64, error)

Sweep is auth.Sweeper: it deletes finished deliveries (delivered, dead, cancelled) older than the retention. Pending ones are never swept. The core's cutoff is for spent tokens and is ignored.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the endpoint registry and the delivery log.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates both tables for engine. No foreign keys, as everywhere in the library (ADR-0002): a delivery outlives a deleted endpoint so its history can still be read.

func (*Plugin) UpdateEndpoint

func (p *Plugin) UpdateEndpoint(ctx context.Context, id string, in EndpointUpdate) (Endpoint, error)

UpdateEndpoint changes an endpoint and returns it as stored.

type RetryPolicy

type RetryPolicy struct {
	// MaxAttempts is how many sends a delivery gets, the first included;
	// 0 is DefaultMaxAttempts.
	MaxAttempts int
	// Base is the wait after the first failure, doubled after each later
	// one; 0 is DefaultRetryBase.
	Base time.Duration
	// Max caps the wait; 0 is DefaultRetryMax.
	Max time.Duration
}

RetryPolicy shapes retries. The zero value of each field is its default; a negative one is refused by auth.New.

type SecurityData

type SecurityData struct {
	ID        string            `json:"id"`
	Kind      string            `json:"kind"`
	Severity  string            `json:"severity"`
	Outcome   string            `json:"outcome"`
	Op        string            `json:"op,omitempty"`
	Plugin    string            `json:"plugin,omitempty"`
	Transport string            `json:"transport,omitempty"`
	UserID    string            `json:"userId,omitempty"`
	IP        string            `json:"ip,omitempty"`
	Attrs     map[string]string `json:"attrs,omitempty"`
}

SecurityData is the data of the security.* events: auth.SecurityEvent, which already carries no password, token or code, with JSON names.

type SessionData

type SessionData struct {
	ID             string    `json:"id"`
	UserID         string    `json:"userId"`
	CreatedAt      time.Time `json:"createdAt"`
	ExpiresAt      time.Time `json:"expiresAt"`
	IP             string    `json:"ip,omitempty"`
	UserAgent      string    `json:"userAgent,omitempty"`
	ImpersonatedBy string    `json:"impersonatedBy,omitempty"`
}

SessionData is the data of the session.* events. It never carries the token. IP is empty under auth.WithoutIPTracking.

type SessionRevokedData

type SessionRevokedData struct {
	SessionData
	Reason string `json:"reason"`
}

SessionRevokedData is the data of session.revoked: the session and why it ended. Reason is an auth.SessionEndReason string ("sign_out", "revoked", "admin_revoked", "banned", "password_changed", "token_reuse", "user_deleted", "expired", …; auth.SessionEndReasonValues lists them). For "sign out everywhere" in stateless mode, where the server holds no list of sessions, ID is empty and the event means every session of UserID.

type Status

type Status string

Status is where a delivery stands.

const (
	// StatusPending: not delivered yet; due at NextAttemptAt.
	StatusPending Status = "pending"
	// StatusDelivered: the receiver answered 2xx.
	StatusDelivered Status = "delivered"
	// StatusDead: dead-lettered after MaxAttempts failures, or at once on a
	// 410 Gone. Replay sends it again as a new delivery.
	StatusDead Status = "dead"
	// StatusCancelled: its endpoint was deleted or disabled before it was
	// delivered.
	StatusCancelled Status = "cancelled"
)

Statuses. Pending is the only one that is ever sent; the other three are final and only Sweep removes them.

type UserData

type UserData struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	Image     string    `json:"image"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

UserData is the data of the user.* events: the profile, never a credential.

Directories

Path Synopsis
Package signature signs and verifies webhooks the Standard Webhooks way (https://www.standardwebhooks.com): three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 over "<id>.<timestamp>.<body>", and secrets written "whsec_" plus base64.
Package signature signs and verifies webhooks the Standard Webhooks way (https://www.standardwebhooks.com): three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 over "<id>.<timestamp>.<body>", and secrets written "whsec_" plus base64.

Jump to

Keyboard shortcuts

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