Framework

Webhooks

The pkg/webhook package provides an HTTP client for delivering outgoing webhook events with HMAC-SHA256 signatures and exponential backoff retry. The signature scheme follows industry conventions (Stripe, Svix). It is built entirely on Go's standard library — no external dependencies.

import "github.com/stanza-go/framework/pkg/webhook"

Creating a client

Create a client with functional options:

client := webhook.NewClient()

Options

OptionDefaultDescription
WithTimeout(d)10sPer-request HTTP timeout
WithMaxRetries(n)3Maximum retry attempts for SendWithRetry (up to n+1 total attempts)
WithRetryBaseDelay(d)1sBase delay for exponential backoff; each retry doubles the delay
WithRetryMaxDelay(d)30sMaximum delay between retries
client := webhook.NewClient(
    webhook.WithTimeout(5 * time.Second),
    webhook.WithMaxRetries(5),
    webhook.WithRetryBaseDelay(2 * time.Second),
    webhook.WithRetryMaxDelay(60 * time.Second),
)

Sending a webhook

Use Send for a single delivery attempt:

result, err := client.Send(ctx, &webhook.Delivery{
    URL:     "https://example.com/webhook",
    Secret:  "whsec_abc123",
    Event:   "user.created",
    Payload: jsonBytes,
})
if err != nil {
    // network error or request creation failure
}
// result.StatusCode, result.Body, result.DeliveryID

Send makes one attempt. A non-2xx response is not an error — inspect Result.StatusCode to determine success.


Sending with retry

Use SendWithRetry for automatic retry with exponential backoff:

result, err := client.SendWithRetry(ctx, &webhook.Delivery{
    URL:     "https://example.com/webhook",
    Secret:  "whsec_abc123",
    Event:   "order.completed",
    Payload: jsonBytes,
})

Retry behavior

ResponseAction
2xxSuccess — return immediately
4xxClient error — return immediately, no retry
5xxServer error — retry with backoff
Network errorRetry with backoff

Backoff doubles with each attempt: 1s, 2s, 4s, 8s, ... capped at retryMaxDelay. The context is checked between retries — cancellation stops the loop.


Delivery struct

type Delivery struct {
    URL     string            // Endpoint URL (required)
    Secret  string            // HMAC-SHA256 signing key (optional)
    Event   string            // Event type, sent as X-Webhook-Event header
    Payload []byte            // Raw JSON body
    Headers map[string]string // Additional headers (added after standard webhook headers)
}

If Secret is empty, no signature headers are added. Custom Headers can override the standard webhook headers if needed.


Result struct

type Result struct {
    StatusCode int    // HTTP status code from the endpoint
    Body       string // Response body (truncated to 64KB)
    Attempts   int    // Total attempts made
    DeliveryID string // Unique delivery ID (format: whd_<hex>)
}

Signature headers

Every delivery includes these headers:

HeaderValueExample
X-Webhook-IDUnique delivery IDwhd_a1b2c3d4e5f6...
X-Webhook-TimestampUnix timestamp1742428800
X-Webhook-EventEvent typeuser.created
X-Webhook-SignatureHMAC-SHA256 hex digeste3b0c44298fc1c14...

The signature is computed over {id}.{timestamp}.{body} using the delivery's secret as the HMAC key. This matches the Stripe/Svix convention and allows recipients to verify authenticity.


Signing and verifying

The package exports Sign and Verify for manual signature operations:

// Compute a signature
sig := webhook.Sign(secret, deliveryID, timestamp, body)

// Verify a received signature
valid := webhook.Verify(secret, deliveryID, timestamp, signature, body)

Verify uses constant-time comparison (hmac.Equal) to prevent timing attacks.

Verifying in a handler

When receiving webhooks from an external system that uses this signature scheme:

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    id := r.Header.Get("X-Webhook-ID")
    ts := r.Header.Get("X-Webhook-Timestamp")
    sig := r.Header.Get("X-Webhook-Signature")

    if !webhook.Verify("whsec_your_secret", id, ts, sig, body) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    // Signature valid — process the event
}

Error handling

// URL is required
var err error
_, err = client.Send(ctx, &webhook.Delivery{})
// err == webhook.ErrNoURL

Network errors and request creation failures are returned as wrapped errors. Non-2xx responses are not errors — they're returned in Result.StatusCode so the caller can decide how to handle them.


Client stats

The client tracks cumulative delivery counters using atomic operations. Call Stats() for a thread-safe snapshot:

stats := client.Stats()
fmt.Println(stats.Sends, stats.Successes, stats.Failures)
FieldTypeDescription
Sendsint64Total Send or SendWithRetry calls
Successesint64Deliveries that received a 2xx response
Failuresint64Deliveries that received a non-2xx response
Retriesint64Retry attempts (only from SendWithRetry)
Errorsint64Network or request-building errors

All counters are cumulative since the client was created. Stats() is safe to call concurrently from any goroutine.


API reference

Function/MethodSignatureDescription
NewClient(opts ...Option) *ClientCreate a client with options
Send(ctx, *Delivery) (*Result, error)Single delivery attempt
SendWithRetry(ctx, *Delivery) (*Result, error)Delivery with exponential backoff retry
Stats() ClientStatsSnapshot of cumulative delivery counters
Sign(secret, id, timestamp string, body []byte) stringCompute HMAC-SHA256 signature
Verify(secret, id, timestamp, signature string, body []byte) boolVerify a signature (constant-time)

Constants

ConstantValueDescription
HeaderIDX-Webhook-IDDelivery ID header
HeaderTimestampX-Webhook-TimestampUnix timestamp header
HeaderSignatureX-Webhook-SignatureHMAC-SHA256 signature header
HeaderEventX-Webhook-EventEvent type header

Tips

  • Always set a secret. Without a secret, recipients cannot verify that the delivery came from your application. Generate secrets with a prefix like whsec_ for easy identification.
  • Use SendWithRetry for async delivery. In the standalone app, webhooks are delivered via the job queue — the queue handles retries. Use Send (single attempt) inside a queue handler and let the queue manage retry logic.
  • Check the status code. A 200 response means the recipient acknowledged the webhook. A 4xx means the recipient rejected it (bad payload, invalid event) and retrying won't help. A 5xx means the recipient's server had an issue and a retry may succeed.
  • Keep payloads small. The response body is truncated to 64KB. Keep your webhook payloads focused on the event data — don't send large blobs.

See the Webhooks recipe for integration patterns with the standalone app's webhook management system.

Previous
Cache