Outbound Webhooks
Outbound webhooks let Forge call your URL whenever content changes. When a post is published, updated, archived, scheduled, or deleted, Forge signs a JSON payload and POSTs it to every registered endpoint that subscribed to that event.
Prerequisites
- SQLite database configured in
forge.Config.DB - An HTTPS endpoint you control (HTTP and private IPs are rejected)
- An Admin token for managing endpoints
Database tables
Create these three tables before calling App.Webhooks. The DDL is included in the godoc for each type — copy it directly into your migration.
CREATE TABLE 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 forge_outbound_jobs (
id TEXT PRIMARY KEY,
endpoint_id TEXT NOT NULL,
target_url TEXT NOT NULL,
secret_enc TEXT NOT NULL,
payload BLOB NOT NULL,
event TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE forge_delivery_logs (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
attempted_at DATETIME NOT NULL,
status_code INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
Wiring
db, _ := sql.Open("sqlite", "./mysite.db")
app := forge.New(forge.MustConfig(forge.Config{
BaseURL: "https://mysite.com",
Secret: []byte(os.Getenv("SECRET")),
DB: db,
}))
// Wire outbound webhooks.
store := forge.NewWebhookStore(db, []byte(os.Getenv("SECRET")))
app.Webhooks(store)
app.Content(myModule)
if err := app.Run(":8080"); err != nil {
log.Fatal(err)
}
App.Webhooks registers the store, creates the background worker pool, and automatically hooks every registered module to dispatch delivery jobs when content changes. You do not need to wire individual modules.
The Titled interface
By default the webhook payload omits a title field. To include a human-readable title, implement the Titled interface on your content type:
type Post struct {
forge.Node
Title string `forge:"required" db:"title"`
Body string `forge:"required" db:"body"`
}
func (p *Post) ContentTitle() string { return p.Title }
When ContentTitle() is present, the title is included in the data object of every event payload for that type.
Payload format
Every delivery is a JSON POST with this envelope:
{
"id": "evt_01j...",
"event": "post.published",
"timestamp": "2026-05-05T18:00:00Z",
"data": {
"type": "post",
"id": "01j...",
"slug": "my-post",
"title": "My Post"
}
}
Supported events:
| Event | Triggered when |
|---|---|
<type>.created | Node first saved as Draft |
<type>.updated | Published node is updated |
<type>.published | Node transitions to Published |
<type>.scheduled | Node is scheduled for future publication |
<type>.archived | Node is archived |
<type>.deleted | Node is permanently deleted |
The <type> prefix matches the lowercased content type name, e.g. post, article, doc.
Verifying signatures
Every request carries four headers:
| Header | Value |
|---|---|
X-Forge-Signature | sha256=<hex> — HMAC-SHA256 of "<timestamp>.<body>" |
X-Forge-Timestamp | Unix timestamp (seconds) used in the signature |
X-Forge-Event | Event name, e.g. post.published |
X-Forge-Delivery | Unique delivery job ID |
To verify in Go:
func verifySignature(secret []byte, timestamp, sig string, body []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
Always check the timestamp to defend against replay attacks — reject requests where X-Forge-Timestamp is more than 5 minutes old.
Secret management
Secrets are generated as 32 random bytes and stored AES-256-GCM encrypted using a key derived from Config.Secret. The plaintext secret is returned once — at endpoint creation — and cannot be retrieved afterwards.
If you rotate Config.Secret: all stored signing secrets become unreadable. Re-create every endpoint after a secret rotation.
Delivery behaviour
The worker pool retries failed deliveries with exponential backoff:
| Attempt | Delay (approx.) |
|---|---|
| 1 | 4 seconds |
| 2 | 16 seconds |
| 3 | 64 seconds |
| 4 | ~4 minutes |
| 5 | ~17 minutes |
| 6 | ~68 minutes (capped at 1 hour) |
| 7 | Dead-lettered |
Each delay includes ±20% random jitter.
A non-2xx HTTP response counts as a failure. A 30-second timeout is enforced per attempt.
Circuit breaker: after 5 consecutive failures from the same endpoint, that endpoint's circuit opens for 5 minutes. All jobs for that endpoint are skipped until the circuit closes. This prevents a misbehaving endpoint from blocking the worker pool.
Dead-letter: after 7 failed attempts the job status is set to dead. Dead jobs are not retried automatically — use retry_webhook (MCP) or forge webhook retry (CLI) to re-queue them.
Managing endpoints
Endpoints can be managed via MCP tools (for AI agents) or forge-cli (for humans and scripts).
MCP tools (Admin role required)
| Tool | Description |
|---|---|
create_webhook | Register a new endpoint. Returns the signing secret once. |
list_webhooks | List all endpoints. Secrets never returned. |
delete_webhook | Remove an endpoint by ID. |
list_webhook_deliveries | Inspect delivery logs for a job or endpoint. |
retry_webhook | Re-queue a dead-lettered job. |
forge-cli
forge webhook create --url https://mysite.com/hooks --events post.published,post.updated
forge webhook list
forge webhook delete ep_abc123
forge webhook deliveries --job job_abc123
forge webhook retry job_abc123
See the forge-cli reference for full flag details.
Security notes
- HTTPS only. HTTP target URLs are rejected at endpoint creation.
- SSRF protection. Private IP ranges (10.x, 192.168.x, 172.16.x–172.31.x), loopback, and
.localaddresses are rejected. - Secrets are write-only. The signing secret cannot be read back after creation.
- Verify every request using the
X-Forge-Signatureheader before processing the payload.