Outbound webhooks
Outbound webhooks let you notify external services whenever content changes state in Forge. Register an HTTPS endpoint and choose which events to subscribe to. Forge will POST a signed JSON payload every time one of those events fires.
Webhook delivery is built on top of the signal bus. Internally, App.Webhooks registers an OnSignal handler for each event you configure — which means webhooks and custom signal bus handlers coexist naturally on the same application.
Wiring
Add App.Webhooks to your application after creating the WebhookStore:
db, _ := sql.Open("sqlite3", "./mysite.db")
webhookStore := forge.NewWebhookStore(db, []byte(os.Getenv("SECRET")))
app := forge.New(forge.MustConfig(forge.Config{
BaseURL: "https://mysite.com",
Secret: []byte(os.Getenv("SECRET")),
DB: db,
}))
app.Webhooks(webhookStore)
app.Run(":8080")
Create the required tables once before starting:
CREATE TABLE IF NOT EXISTS forge_webhook_endpoints (
id TEXT PRIMARY KEY,
events TEXT NOT NULL,
target_url TEXT NOT NULL,
secret_enc TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS forge_webhook_jobs (
id TEXT PRIMARY KEY,
endpoint_id TEXT NOT NULL,
event TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS forge_webhook_deliveries (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
status_code INTEGER,
error TEXT,
attempted_at DATETIME NOT NULL
);
Available events
Each event corresponds to a lifecycle signal. Subscribe to one or many.
| Event string | Signal | Fires when |
|---|---|---|
after_create | AfterCreate | A new item is persisted |
after_update | AfterUpdate | An existing item's fields change |
after_publish | AfterPublish | An item transitions to Published |
after_unpublish | AfterUnpublish | An item leaves Published status |
after_archive | AfterArchive | An item transitions to Archived |
after_schedule | AfterSchedule | An item transitions to Scheduled |
after_delete | AfterDelete | An item is permanently deleted |
Pass the string form when registering endpoints (via MCP or Go API):
store.Create(ctx, "https://example.com/hook", []string{"after_publish", "after_delete"})
Registering endpoints
Go API
// Register an endpoint — returns the WebhookEndpoint and the plaintext signing secret
ep, secret, err := store.Create(ctx,
"https://example.com/hooks/forge",
[]string{"after_publish", "after_archive"},
)
// secret is returned once — store it securely. It cannot be retrieved again.
// List all registered endpoints
endpoints, err := store.List(ctx)
// Remove an endpoint
err = store.Delete(ctx, ep.ID)
MCP tools (Admin role required)
When App.Webhooks is configured, four MCP tools become available:
| Tool | Description |
|---|---|
create_webhook | Register a new endpoint. Returns signing secret once. |
list_webhooks | List all endpoints with delivery statistics. |
delete_webhook | Remove an endpoint by ID. |
list_webhook_deliveries | Show delivery log for a specific job. |
retry_webhook | Re-queue a dead job for delivery. |
create_webhook requires url (HTTPS) and events (array of event strings). The signing secret is returned once — it is not retrievable from list_webhooks.
Security
SSRF protection
Forge validates the target URL when an endpoint is registered — not at delivery time. HTTP URLs, private IP ranges, loopback addresses, and .local hostnames are rejected. A prompt-injection attack that tries to redirect delivery to an internal service is blocked at registration, before any job is ever enqueued.
Payload signing
Every outbound request includes an X-Forge-Signature header:
X-Forge-Signature: sha256=<HMAC-SHA256 of the raw payload body>
Verify it on your receiving end:
func verifySignature(secret []byte, body []byte, sig string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
Secrets are stored AES-256-GCM encrypted in the database, derived from Config.Secret. Rotating Config.Secret invalidates all stored secrets — update your endpoints accordingly.
Payload format
All events share the same payload envelope:
{
"event": "after_publish",
"type": "Post",
"slug": "hello-world",
"title": "Hello World",
"url": "https://mysite.com/posts/hello-world",
"timestamp": "2026-05-11T10:00:00Z",
"previous_state": "draft",
"actor_role": "editor",
"actor_id": "usr_01234"
}
previous_state is empty ("") for after_create. For after_publish on a newly created item that was immediately published, it is "draft".
Delivery and retry
Forge delivers webhooks with automatic retry and exponential backoff:
- Initial attempt within seconds of the event firing
- Up to 5 retry attempts with exponential backoff
- A circuit breaker pauses delivery to endpoints that are consistently failing
- Each attempt is recorded in
forge_webhook_deliveries
Delivery runs in a bounded background worker pool — it does not block HTTP request handling.
To inspect or retry failed deliveries, use the list_webhook_deliveries and retry_webhook MCP tools, or query forge_webhook_deliveries directly.
Relationship to the signal bus
Webhook delivery is one subscriber on the signal bus. Your application can register additional OnSignal handlers alongside webhooks:
app.Webhooks(webhookStore) // registers OnSignal handlers for all configured events
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
// fires on the same publish event, independently of webhook delivery
return cache.Invalidate(ev.URL)
})
Both handlers fire for every publish event. One failing handler does not block the other.
For custom in-process reactions to content events (audit logs, cache invalidation, SSE push), see the Signal bus page.