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
- An admin creates a webhook via the admin panel or API, specifying a URL and which events to subscribe to
- Your module code calls
dispatcher.Dispatch(ctx, "event.name", payload)when something happens - The dispatcher finds all active webhooks subscribed to that event
- A delivery record is created in the
webhook_deliveriestable - A job is enqueued for async delivery via the queue
- The queue worker delivers the webhook with HMAC-SHA256 signature headers
- 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:
| Constant | Value | Description |
|---|---|---|
EventUserRegistered | user.registered | User self-registered |
EventUserCreated | user.created | Admin created a user |
EventUserUpdated | user.updated | User profile was updated |
EventUserDeleted | user.deleted | User was deleted |
EventUserBulkDeleted | user.bulk_deleted | Users were bulk deleted |
EventAdminCreated | admin.created | Admin was created |
EventAdminUpdated | admin.updated | Admin was updated |
EventAdminDeleted | admin.deleted | Admin was deleted |
EventAdminBulkDeleted | admin.bulk_deleted | Admins were bulk deleted |
EventRoleCreated | role.created | Role was created |
EventRoleUpdated | role.updated | Role was updated |
EventRoleDeleted | role.deleted | Role was deleted |
EventSessionRevoked | session.revoked | Session was revoked |
EventSessionBulkRevoked | session.bulk_revoked | Sessions were bulk revoked |
EventSettingUpdated | setting.updated | Setting was updated |
EventWebhookTest | webhook.test | Test 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:
| Pattern | Matches |
|---|---|
* | All events |
user.* | All user events (user.created, user.updated, etc.) |
order.created | Only 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
}
| Table | Purpose |
|---|---|
webhooks | Registered webhook endpoints with URL, secret, and event subscriptions |
webhook_deliveries | Delivery 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:
| Header | Purpose |
|---|---|
X-Webhook-ID | Unique delivery ID |
X-Webhook-Timestamp | Unix timestamp when the delivery was created |
X-Webhook-Signature | HMAC-SHA256 hex digest |
X-Webhook-Event | Event 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 createswhsec_-prefixed secrets with 24 random bytes. Don't let users set their own secrets. - Events are stored as JSON arrays. The
eventscolumn in thewebhookstable is a JSON string like["user.*", "order.created"]. The wildcard*matches all events. - Delivery is async.
Dispatchnever 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_bodyinwebhook_deliveriesstores up to 4KB of the recipient's response for debugging. - Delivery retention. The built-in
purge-old-webhook-deliveriescron 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}/testto verify the endpoint works before subscribing to real events.