Documentation
Features

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 stringSignalFires when
after_createAfterCreateA new item is persisted
after_updateAfterUpdateAn existing item's fields change
after_publishAfterPublishAn item transitions to Published
after_unpublishAfterUnpublishAn item leaves Published status
after_archiveAfterArchiveAn item transitions to Archived
after_scheduleAfterScheduleAn item transitions to Scheduled
after_deleteAfterDeleteAn 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:

ToolDescription
create_webhookRegister a new endpoint. Returns signing secret once.
list_webhooksList all endpoints with delivery statistics.
delete_webhookRemove an endpoint by ID.
list_webhook_deliveriesShow delivery log for a specific job.
retry_webhookRe-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.