Documentation
Guides

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:

EventTriggered when
<type>.createdNode first saved as Draft
<type>.updatedPublished node is updated
<type>.publishedNode transitions to Published
<type>.scheduledNode is scheduled for future publication
<type>.archivedNode is archived
<type>.deletedNode is permanently deleted

The <type> prefix matches the lowercased content type name, e.g. post, article, doc.


Verifying signatures

Every request carries four headers:

HeaderValue
X-Forge-Signaturesha256=<hex> — HMAC-SHA256 of "<timestamp>.<body>"
X-Forge-TimestampUnix timestamp (seconds) used in the signature
X-Forge-EventEvent name, e.g. post.published
X-Forge-DeliveryUnique 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:

AttemptDelay (approx.)
14 seconds
216 seconds
364 seconds
4~4 minutes
5~17 minutes
6~68 minutes (capped at 1 hour)
7Dead-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)

ToolDescription
create_webhookRegister a new endpoint. Returns the signing secret once.
list_webhooksList all endpoints. Secrets never returned.
delete_webhookRemove an endpoint by ID.
list_webhook_deliveriesInspect delivery logs for a job or endpoint.
retry_webhookRe-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 .local addresses are rejected.
  • Secrets are write-only. The signing secret cannot be read back after creation.
  • Verify every request using the X-Forge-Signature header before processing the payload.