Help us shape the future of Go! Take the 2026 Go Developer Survey

webhook

package
v0.3.4 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: Apache-2.0 Imports: 12 Imported by: 2

Documentation

Overview

Package webhook verifies signed webhook requests: an HMAC-SHA256 signature over the raw body, and optionally a delivery ID and a timestamp checked against a replay window (ADR-0085). NewStandard verifies the Standard Webhooks scheme that Svix, Resend and Clerk use; NewHMAC covers other senders, such as GitHub or Shopify.

A Verifier reads only headers and the body, so it works with any router. In a gorbital app, guard.Webhook runs it before the route's input is parsed:

v, err := webhook.NewStandard(webhook.StandardConfig{Secrets: []string{secret}})
gorbital.Post(r, "/v1/webhooks/payments", h.paymentEvent, guard.Public(), guard.Webhook(v))

Verification proves who sent a request and that it wasn't changed; it doesn't stop a sender from delivering the same event twice. Handlers act idempotently, or remember the delivery ID for at least the tolerance.

Stability: experimental until v0.2.0 (ADR-0015, ADR-0085).

Index

Examples

Constants

View Source
const DefaultTolerance = 5 * time.Minute

DefaultTolerance is how far a signed timestamp may be from the receiver's clock, either way, unless a configuration sets another.

View Source
const MaxIDLength = 255

MaxIDLength bounds a signed delivery ID, which receivers may store to refuse replays.

View Source
const MinSecretBytes = 16

MinSecretBytes is the shortest secret NewHMAC and NewStandard accept.

View Source
const StandardSecretPrefix = "whsec_"

StandardSecretPrefix starts Standard Webhooks signing secrets.

Variables

View Source
var (
	// ErrInvalidSignature reports a request without the expected headers,
	// or whose signatures don't match the body with any secret.
	ErrInvalidSignature = errors.New("webhook: invalid signature")
	// ErrTimestamp reports a request signed further than the tolerance from
	// now, either way. It wraps [ErrInvalidSignature].
	ErrTimestamp = fmt.Errorf("%w: timestamp outside the tolerance", ErrInvalidSignature)
)

Errors returned by HMAC.Verify. Check them with errors.Is; answer both with the same 401, so a sender doesn't learn which check failed.

Functions

func DecodeStandardSecret

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

DecodeStandardSecret decodes a Standard Webhooks signing secret, with or without its "whsec_" prefix, so apps can refuse a mistyped secret when they start. It returns an error, never quoting the secret, when the secret is empty, isn't base64 or is shorter than MinSecretBytes bytes.

Example
package main

import (
	"fmt"

	"gorbital.dev/webhook"
)

func main() {
	_, err := webhook.DecodeStandardSecret("whsec_c2hvcnQ=")
	fmt.Println(err)
}
Output:
webhook: the signing secret must be whsec_ followed by base64 of at least 16 bytes

Types

type Encoding

type Encoding int

Encoding is how a signature is written in its header.

Example
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"net/http"

	"gorbital.dev/webhook"
)

func main() {
	// Shopify: base64 (the default) over the body, no prefix.
	shopify, err := webhook.NewHMAC(webhook.HMACConfig{
		Secrets:         [][]byte{[]byte("shpss_0123456789abcdef0123456789")}, // gitleaks:allow (example)
		SignatureHeader: "X-Shopify-Hmac-Sha256",
		Encoding:        webhook.Base64,
	})
	if err != nil {
		panic(err)
	}
	mac := hmac.New(sha256.New, []byte("shpss_0123456789abcdef0123456789"))
	mac.Write([]byte(`{"id":1}`))
	header := http.Header{"X-Shopify-Hmac-Sha256": {base64.StdEncoding.EncodeToString(mac.Sum(nil))}}
	fmt.Println(shopify.Verify(context.Background(), header, []byte(`{"id":1}`)))
}
Output:
<nil>
const (
	// Base64 is standard base64 with padding (Standard Webhooks, Shopify).
	Base64 Encoding = iota
	// Hex is hexadecimal, in either case (GitHub, Slack).
	Hex
)

Signature encodings.

type HMAC

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

HMAC verifies HMAC-SHA256 webhook signatures. Create one with NewHMAC or NewStandard. It is safe for concurrent use.

Example
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"gorbital.dev/webhook"
)

// exampleSecret is a Standard Webhooks secret for the examples.
const exampleSecret = "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="

// signed returns the headers a Standard Webhooks sender sets for body.
func signed(id string, at time.Time, body string) http.Header {
	key, _ := webhook.DecodeStandardSecret(exampleSecret)
	ts := strconv.FormatInt(at.Unix(), 10)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "." + body))
	h := http.Header{}
	h.Set("webhook-id", id)
	h.Set("webhook-timestamp", ts)
	h.Set("webhook-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil)))
	return h
}

func main() {
	// One *HMAC serves every request; build it once at start.
	v, err := webhook.NewStandard(webhook.StandardConfig{Secrets: []string{exampleSecret}})
	if err != nil {
		panic(err)
	}
	body := `{"type":"invoice.paid"}`
	for _, id := range []string{"msg_1", "msg_2"} {
		fmt.Println(id, v.Verify(context.Background(), signed(id, time.Now(), body), []byte(body)))
	}
}
Output:
msg_1 <nil>
msg_2 <nil>

func NewHMAC

func NewHMAC(cfg HMACConfig) (*HMAC, error)

NewHMAC returns a verifier for cfg. It returns an error when there is no secret, a secret is shorter than MinSecretBytes, SignatureHeader is empty, the encoding is unknown or Tolerance is negative. Errors never quote a secret.

Example
package main

import (
	"context"
	"fmt"
	"net/http"

	"gorbital.dev/webhook"
)

func main() {
	// GitHub: "X-Hub-Signature-256: sha256=<hex>" over the body alone.
	v, err := webhook.NewHMAC(webhook.HMACConfig{
		Secrets:         [][]byte{[]byte("It's a Secret to Everybody")}, // gitleaks:allow (GitHub's published test vector)
		SignatureHeader: "X-Hub-Signature-256",
		SignaturePrefix: "sha256=",
		Encoding:        webhook.Hex,
	})
	if err != nil {
		panic(err)
	}
	header := http.Header{}
	header.Set("X-Hub-Signature-256", "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17")
	fmt.Println(v.Verify(context.Background(), header, []byte("Hello, World!")))
}
Output:
<nil>

func NewStandard

func NewStandard(cfg StandardConfig) (*HMAC, error)

NewStandard returns a verifier for the Standard Webhooks scheme (https://www.standardwebhooks.com): headers "<prefix>id", "<prefix>timestamp" (Unix seconds) and "<prefix>signature" (space-separated "v1,<base64>" entries), signed with HMAC-SHA256 over "<id>.<timestamp>.<body>". Retries of one event keep the ID and get a new timestamp. It returns an error when there is no secret or a secret isn't base64 of at least MinSecretBytes bytes, without quoting it.

Example
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"gorbital.dev/webhook"
)

// exampleSecret is a Standard Webhooks secret for the examples.
const exampleSecret = "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="

// signed returns the headers a Standard Webhooks sender sets for body.
func signed(id string, at time.Time, body string) http.Header {
	key, _ := webhook.DecodeStandardSecret(exampleSecret)
	ts := strconv.FormatInt(at.Unix(), 10)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "." + body))
	h := http.Header{}
	h.Set("webhook-id", id)
	h.Set("webhook-timestamp", ts)
	h.Set("webhook-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil)))
	return h
}

func main() {
	v, err := webhook.NewStandard(webhook.StandardConfig{Secrets: []string{exampleSecret}})
	if err != nil {
		panic(err)
	}
	body := `{"type":"payment.succeeded"}`
	header := signed("msg_1", time.Now(), body)

	fmt.Println(v.Verify(context.Background(), header, []byte(body)))
	err = v.Verify(context.Background(), header, []byte(`{"type":"payment.refunded"}`))
	fmt.Println(errors.Is(err, webhook.ErrInvalidSignature))
}
Output:
<nil>
true
Example (Svix)
package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"gorbital.dev/webhook"
)

func main() {
	// Resend and Clerk send Svix's header names.
	v, err := webhook.NewStandard(webhook.StandardConfig{
		Secrets:      []string{"whsec_plJ3nmyCDGBKInavdOK15jsl"}, // gitleaks:allow (Svix's published test vector)
		HeaderPrefix: "svix-",
		Now:          func() time.Time { return time.Unix(1731705121, 0) },
	})
	if err != nil {
		panic(err)
	}
	header := http.Header{}
	header.Set("svix-id", "msg_loFOjxBNrRLzqYUf")
	header.Set("svix-timestamp", "1731705121")
	header.Set("svix-signature", "v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0=")
	fmt.Println(v.Verify(context.Background(), header, []byte(`{"event_type":"ping","data":{"success":true}}`)))
}
Output:
<nil>

func (*HMAC) Verify

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

Verify checks a request's headers and raw body. It returns nil when the configured headers are present and well formed, the timestamp (if any) is within the tolerance, and at least one signature entry is the HMAC-SHA256 of the signed content with one of the secrets. It returns ErrTimestamp for a timestamp outside the tolerance and ErrInvalidSignature for anything else. Every entry is compared with every secret in constant time, so timing doesn't reveal which one matched.

Example
package main

import (
	"io"
	"net/http"

	"gorbital.dev/webhook"
)

// exampleSecret is a Standard Webhooks secret for the examples.
const exampleSecret = "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="

func main() {
	v, err := webhook.NewStandard(webhook.StandardConfig{Secrets: []string{exampleSecret}})
	if err != nil {
		panic(err)
	}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /webhooks/payments", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			w.WriteHeader(http.StatusRequestEntityTooLarge)
			return
		}
		if err := v.Verify(r.Context(), r.Header, body); err != nil {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		// Act on the event, idempotently: retries keep the webhook-id.
		w.WriteHeader(http.StatusNoContent)
	})
}

type HMACConfig

type HMACConfig struct {
	// Secrets are the signing keys. A signature made with any of them is
	// accepted, so a sender's secret can be rotated: add the new one, deploy,
	// switch the sender, then remove the old one. Each is at least
	// [MinSecretBytes] long.
	Secrets [][]byte
	// SignatureHeader holds the signature: one entry, or several separated by
	// spaces. Required.
	SignatureHeader string
	// SignaturePrefix starts every accepted entry, such as "v1," or
	// "sha256="; entries with another prefix are ignored.
	SignaturePrefix string
	// Encoding is how the signature after the prefix is encoded.
	Encoding Encoding
	// IDHeader, when set, holds a delivery ID that is part of the signed
	// content. It must be present, at most [MaxIDLength] bytes, and contain
	// no dots or whitespace.
	IDHeader string
	// TimestampHeader, when set, holds the Unix time in seconds at which the
	// request was signed, which is part of the signed content. Requests
	// signed further than Tolerance from now are refused, so a captured
	// request can't be replayed later. Without it there is no replay window:
	// use it whenever the sender signs a timestamp.
	TimestampHeader string
	// Tolerance is the replay window either side of now. Default:
	// [DefaultTolerance].
	Tolerance time.Duration
	// Signed writes the signed content for a request to w. Default: the
	// ID, the timestamp and the body, each present part followed by a dot
	// except the body ("id.timestamp.body", "timestamp.body" or "body").
	Signed func(w io.Writer, id, timestamp string, body []byte)
	// Now returns the current time. Default: [time.Now].
	Now func() time.Time
}

HMACConfig configures NewHMAC.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"

	"gorbital.dev/webhook"
)

func main() {
	// Slack signs "v0:<timestamp>:<body>" and sends "v0=<hex>".
	v, err := webhook.NewHMAC(webhook.HMACConfig{
		Secrets:         [][]byte{[]byte("8f742231b10e8888abcd99yyyzzz85a5")}, // gitleaks:allow (example)
		SignatureHeader: "X-Slack-Signature",
		SignaturePrefix: "v0=",
		Encoding:        webhook.Hex,
		TimestampHeader: "X-Slack-Request-Timestamp",
		Signed: func(w io.Writer, _, timestamp string, body []byte) {
			_, _ = io.WriteString(w, "v0:"+timestamp+":")
			_, _ = w.Write(body)
		},
	})
	if err != nil {
		panic(err)
	}
	header := http.Header{}
	header.Set("X-Slack-Request-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
	header.Set("X-Slack-Signature", "v0=0000")
	fmt.Println(errors.Is(v.Verify(context.Background(), header, []byte("token=x")), webhook.ErrInvalidSignature))
}
Output:
true

type StandardConfig

type StandardConfig struct {
	// Secrets are signing secrets as the sender shows them: "whsec_"
	// followed by base64, or the base64 alone. Several are accepted during a
	// rotation.
	Secrets []string
	// HeaderPrefix starts the three header names. Default: "webhook-"
	// (webhook-id, webhook-timestamp, webhook-signature). Use "svix-" for
	// senders that use Svix's names, such as Resend and Clerk.
	HeaderPrefix string
	// Tolerance is the replay window either side of now. Default:
	// [DefaultTolerance].
	Tolerance time.Duration
	// Now returns the current time. Default: [time.Now].
	Now func() time.Time
}

StandardConfig configures NewStandard.

Example
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"gorbital.dev/webhook"
)

// exampleSecret is a Standard Webhooks secret for the examples.
const exampleSecret = "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="

// signed returns the headers a Standard Webhooks sender sets for body.
func signed(id string, at time.Time, body string) http.Header {
	key, _ := webhook.DecodeStandardSecret(exampleSecret)
	ts := strconv.FormatInt(at.Unix(), 10)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "." + body))
	h := http.Header{}
	h.Set("webhook-id", id)
	h.Set("webhook-timestamp", ts)
	h.Set("webhook-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil)))
	return h
}

func main() {
	// During a rotation, accept the old and the new secret.
	v, err := webhook.NewStandard(webhook.StandardConfig{
		Secrets: []string{
			"whsec_ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=", // gitleaks:allow (example: the new secret)
			exampleSecret, // the old secret
		},
		Tolerance: 3 * time.Minute,
	})
	if err != nil {
		panic(err)
	}
	body := `{}`
	fmt.Println(v.Verify(context.Background(), signed("msg_1", time.Now(), body), []byte(body)))
	err = v.Verify(context.Background(), signed("msg_2", time.Now().Add(-4*time.Minute), body), []byte(body))
	fmt.Println(errors.Is(err, webhook.ErrTimestamp))
}
Output:
<nil>
true

type Verifier

type Verifier interface {
	Verify(ctx context.Context, header http.Header, body []byte) error
}

A Verifier checks that a webhook request comes from its sender: header is the request's headers and body its raw body, exactly as received. It returns nil for an authentic request, and an error wrapping ErrInvalidSignature otherwise. Implementations are safe for concurrent use.

Example
package main

import (
	"context"
	"fmt"
	"net/http"

	"gorbital.dev/webhook"
)

func main() {
	// Any type with a Verify method can guard a route, such as a verifier
	// for a sender that signs with a public key.
	var v webhook.Verifier = verifierFunc(func(_ context.Context, header http.Header, _ []byte) error {
		if header.Get("X-Test-Signature") != "ok" {
			return webhook.ErrInvalidSignature
		}
		return nil
	})
	fmt.Println(v.Verify(context.Background(), http.Header{"X-Test-Signature": {"ok"}}, nil))
}

type verifierFunc func(ctx context.Context, header http.Header, body []byte) error

func (f verifierFunc) Verify(ctx context.Context, header http.Header, body []byte) error {
	return f(ctx, header, body)
}
Output:
<nil>

Jump to

Keyboard shortcuts

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