Recipes

Webhooks

The standalone app includes a full webhook management system — admins can register webhook endpoints, subscribe to events, and receive HTTP callbacks with HMAC-SHA256 signatures when events occur. This recipe shows how to emit webhook events from your modules and how recipients verify signatures.


How it works

  1. An admin creates a webhook via the admin panel or API, specifying a URL and which events to subscribe to
  2. Your module code calls dispatcher.Dispatch(ctx, "event.name", payload) when something happens
  3. The dispatcher finds all active webhooks subscribed to that event
  4. A delivery record is created in the webhook_deliveries table
  5. A job is enqueued for async delivery via the queue
  6. The queue worker delivers the webhook with HMAC-SHA256 signature headers
  7. Failed deliveries are retried automatically (up to 4 total attempts)

Dispatching events

Inject the Dispatcher into your module and call Dispatch when events occur. Define event constants in your module to prevent typos and enable grep-ability:

package orders

import (
    "github.com/stanza-go/framework/pkg/http"
    "github.com/stanza-go/framework/pkg/sqlite"
    "github.com/stanza-go/standalone/module/webhooks"
)

// Event constants for this module.
const (
    EventOrderCreated   = "order.created"
    EventOrderCompleted = "order.completed"
)

func Register(api *http.Group, db *sqlite.DB, dispatcher *webhooks.Dispatcher) {
    api.HandleFunc("POST /orders", createHandler(db, dispatcher))
}

func createHandler(db *sqlite.DB, dispatcher *webhooks.Dispatcher) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        // ... create the order ...

        // Dispatch webhook event (async — returns immediately)
        _ = dispatcher.Dispatch(r.Context(), EventOrderCreated, map[string]any{
            "id":     order.ID,
            "total":  order.Total,
            "status": "pending",
        })

        http.WriteJSON(w, http.StatusCreated, map[string]any{"order": order})
    }
}

Dispatch is fire-and-forget — it enqueues jobs for matching webhooks and returns. The actual HTTP delivery happens asynchronously in the queue worker.


Event naming convention

Use entity.verb format for event names. Always define event names as constants — never use bare strings.

Built-in events

The webhooks package exports constants and a KnownEvents list for all events the standalone app dispatches:

ConstantValueDescription
EventUserRegistereduser.registeredUser self-registered
EventUserCreateduser.createdAdmin created a user
EventUserUpdateduser.updatedUser profile was updated
EventUserDeleteduser.deletedUser was deleted
EventUserBulkDeleteduser.bulk_deletedUsers were bulk deleted
EventAdminCreatedadmin.createdAdmin was created
EventAdminUpdatedadmin.updatedAdmin was updated
EventAdminDeletedadmin.deletedAdmin was deleted
EventAdminBulkDeletedadmin.bulk_deletedAdmins were bulk deleted
EventRoleCreatedrole.createdRole was created
EventRoleUpdatedrole.updatedRole was updated
EventRoleDeletedrole.deletedRole was deleted
EventSessionRevokedsession.revokedSession was revoked
EventSessionBulkRevokedsession.bulk_revokedSessions were bulk revoked
EventSettingUpdatedsetting.updatedSetting was updated
EventWebhookTestwebhook.testTest event from admin panel

The KnownEvents slice pairs each constant with a human-readable label. It is served by the events discovery endpoint (see below) so the admin panel can populate event dropdowns dynamically.

Custom events

When adding webhook events to your own modules, define constants in the module package and register them in your Register function so they appear in the discovery endpoint:

const (
    EventOrderCreated   = "order.created"
    EventOrderCompleted = "order.completed"
)

func Register(api *http.Group, db *sqlite.DB, dispatcher *webhooks.Dispatcher) {
    webhooks.KnownEvents = append(webhooks.KnownEvents,
        webhooks.Event{Name: EventOrderCreated, Label: "Order Created"},
        webhooks.Event{Name: EventOrderCompleted, Label: "Order Completed"},
    )

    api.HandleFunc("POST /orders", createHandler(db, dispatcher))
}

Wildcard subscriptions

Admins can subscribe to specific events or use wildcards:

PatternMatches
*All events
user.*All user events (user.created, user.updated, etc.)
order.createdOnly order.created

Wiring the dispatcher

The dispatcher is created via a provider function and injected through lifecycle DI:

func provideWebhookDispatcher(db *sqlite.DB, q *queue.Queue, logger *log.Logger) *webhooks.Dispatcher {
    return webhooks.NewDispatcher(db, q, logger)
}

It registers a webhook.deliver queue handler automatically. When a job is picked up, the worker delivers the webhook via pkg/webhook.Client.Send and updates the delivery record with the result.


Migration

The webhook system uses two tables:

func createWebhooksUp(tx *sqlite.Tx) error {
    _, err := tx.Exec(`CREATE TABLE webhooks (
        id          INTEGER PRIMARY KEY AUTOINCREMENT,
        url         TEXT    NOT NULL,
        secret      TEXT    NOT NULL,
        description TEXT    NOT NULL DEFAULT '',
        events      TEXT    NOT NULL DEFAULT '["*"]',
        is_active   INTEGER NOT NULL DEFAULT 1,
        created_by  INTEGER NOT NULL DEFAULT 0,
        created_at  TEXT    NOT NULL,
        updated_at  TEXT    NOT NULL
    )`)
    if err != nil {
        return err
    }

    _, err = tx.Exec(`CREATE TABLE webhook_deliveries (
        id            INTEGER PRIMARY KEY AUTOINCREMENT,
        webhook_id    INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
        delivery_id   TEXT    NOT NULL DEFAULT '',
        event         TEXT    NOT NULL,
        payload       TEXT    NOT NULL DEFAULT '{}',
        status        TEXT    NOT NULL DEFAULT 'pending',
        status_code   INTEGER NOT NULL DEFAULT 0,
        response_body TEXT    NOT NULL DEFAULT '',
        attempts      INTEGER NOT NULL DEFAULT 0,
        created_at    TEXT    NOT NULL,
        completed_at  TEXT
    )`)
    return err
}
TablePurpose
webhooksRegistered webhook endpoints with URL, secret, and event subscriptions
webhook_deliveriesDelivery history with status, response, and attempt count

Admin API endpoints

GET    /api/admin/webhooks              — list all webhooks (paginated, searchable)
POST   /api/admin/webhooks              — create a new webhook
GET    /api/admin/webhooks/events       — list all known event types
GET    /api/admin/webhooks/{id}         — webhook detail with delivery stats
PUT    /api/admin/webhooks/{id}         — update URL, events, or active status
DELETE /api/admin/webhooks/{id}         — delete webhook and all deliveries
GET    /api/admin/webhooks/{id}/deliveries — delivery history (filterable by status)
POST   /api/admin/webhooks/{id}/test    — send a test event

All endpoints require the admin:webhooks scope.

Discovering available events

curl http://localhost:23710/api/admin/webhooks/events

Returns all events from KnownEvents with their labels:

{
  "events": [
    {"name": "user.registered", "label": "User Registered"},
    {"name": "user.created", "label": "User Created"},
    ...
  ]
}

The admin panel uses this endpoint to populate event selection dropdowns. When you append custom events to KnownEvents, they appear here automatically.

Webhook URLs are validated with validate.PublicURL to prevent SSRF — URLs pointing to localhost, private networks (10.x, 172.16-31.x, 192.168.x), or other reserved addresses are rejected.

Creating a webhook

curl -X POST http://localhost:23710/api/admin/webhooks \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhook",
    "description": "Order notifications",
    "events": ["order.created", "order.completed"]
  }'

The response includes the auto-generated secret (whsec_...). Secrets are shown once at creation — store them securely.

Sending a test event

curl -X POST http://localhost:23710/api/admin/webhooks/1/test

This dispatches a webhook.test event through the normal delivery pipeline so the recipient can verify their endpoint works.


Admin panel

The admin panel includes two webhook pages:

  • Webhooks list (/admin/webhooks) — table of all webhooks with URL, event count, active status, and actions
  • Webhook detail (/admin/webhooks/:id) — webhook info, delivery stats (total/success/failed), and delivery history table with status badges

Signature verification (for recipients)

Recipients verify webhook authenticity by recomputing the HMAC-SHA256 signature. Every delivery includes four headers:

HeaderPurpose
X-Webhook-IDUnique delivery ID
X-Webhook-TimestampUnix timestamp when the delivery was created
X-Webhook-SignatureHMAC-SHA256 hex digest
X-Webhook-EventEvent type (e.g. order.created)

The signature is computed over {id}.{timestamp}.{body} using the webhook secret as the HMAC key:

Go

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

valid := webhook.Verify(secret,
    r.Header.Get("X-Webhook-ID"),
    r.Header.Get("X-Webhook-Timestamp"),
    r.Header.Get("X-Webhook-Signature"),
    body,
)

Node.js

const crypto = require('crypto');

function verify(secret, id, timestamp, signature, body) {
    const content = `${id}.${timestamp}.${body}`;
    const expected = crypto
        .createHmac('sha256', secret)
        .update(content)
        .digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature),
    );
}

Python

import hmac
import hashlib

def verify(secret, id, timestamp, signature, body):
    content = f"{id}.{timestamp}.{body}".encode()
    expected = hmac.new(secret.encode(), content, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Tips

  • Secrets are auto-generated. The GenerateSecret() function creates whsec_-prefixed secrets with 24 random bytes. Don't let users set their own secrets.
  • Events are stored as JSON arrays. The events column in the webhooks table is a JSON string like ["user.*", "order.created"]. The wildcard * matches all events.
  • Delivery is async. Dispatch never blocks the request — it enqueues jobs and returns. This means the response to the client is not delayed by webhook delivery.
  • Failed deliveries are retried. The queue retries failed jobs up to 4 total attempts with exponential backoff. After exhausting retries, the delivery is marked as failed.
  • Response body is truncated. The response_body in webhook_deliveries stores up to 4KB of the recipient's response for debugging.
  • Delivery retention. The built-in purge-old-webhook-deliveries cron job removes delivery records older than 30 days to keep the table lean.
  • Test before going live. Use the admin panel's "Send test" button or POST /api/admin/webhooks/{id}/test to verify the endpoint works before subscribing to real events.
Previous
Caching