Webhooks

HTTPS POST deliveries on agent and session events. HMAC signing, retry, delivery history.

HTTPS POST deliveries fired when interesting things happen on a session or project. Configure from Settings → Webhooks.

A webhook is one direction: pi-forge → your endpoint. If you want the opposite (your script reads agent events), use the SSE stream documented in sse-events.md. The two systems are independent — disabling webhooks doesn't affect SSE.

Events

Seven event types in v1:

Event When it fires
agent_end An agent turn finished. Payload includes stopReason, errorMessage (if any), the assistant's text content, usage stats, and the provider/model used.
ask_user_question The agent put up a multi-choice prompt via the ask_user_question tool and is waiting on a human answer. Payload includes the questions array.
process_alert A background process the agent spawned via the process tool exited. Same trigger conditions as the in-chat agent alert — success / failure / external kill — see processes.md.
auto_retry_end (failures only) The SDK's auto-retry on a provider error exhausted. Useful for paging when an upstream LLM provider goes down.
compaction_end (non-aborted only) Session context was compacted. Payload includes tokensBefore and the compaction reason.
session_created A new session was created in a project.
session_deleted A session was deleted (cold or live). Payload's wasLive distinguishes "we disposed a running session" from "we removed an on-disk-only JSONL."

Scoping

Each webhook is either:

Both can coexist. The UI shows global + per-project for the currently selected project; switching project re-filters.

Delivery, retry, and timeouts

Security

HTTPS-only

The URL must be https://. The route validates this; http://, ssh://, file URLs all 400 with unsupported_protocol.

HMAC-SHA256 signatures

Configure a secret on the webhook to get signed deliveries. Every POST then carries:

X-Pi-Forge-Signature: sha256=<hex digest of the raw JSON body>

Convention matches GitHub's webhook signing. Verify on the receiver side:

import hmac, hashlib

def verify(secret: bytes, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Secrets are stored in ${FORGE_DATA_DIR}/webhooks.json (mode 0600) and never returned over the wire — the API surfaces a hasSecret: boolean flag instead of the value. Same posture as auth.json provider keys.

Self-signed / corporate CA endpoints

For internal webhook targets behind a private CA, set the per-webhook "Allow self-signed / invalid TLS certificate" checkbox. Every delivery for that webhook then uses an https.Agent({ rejectUnauthorized: false }) and writes a webhook-insecure-tls line to stderr — so the relaxed posture is visible in docker logs.

The flag only relaxes cert validation. The URL must still be HTTPS; insecureTls won't accept http://.

Custom headers (Bearer tokens, etc.)

Each webhook can carry a set of static request headers — typical use is Authorization: Bearer <token> for receivers that need their own auth. Headers are stored alongside the rest of the config but treated as secret:

Reserved headers

The following headers are set by pi-forge on every delivery and cannot be overridden by config:

A configured header with one of these names is silently dropped.

Delivery history

Every attempt (success or failure) is appended to ${FORGE_DATA_DIR}/webhook-deliveries.json, capped at 100 per webhook (rolling FIFO). Surfaced in Settings → Webhooks under the per-webhook drawer, and via GET /webhooks/:id/deliveries.

Use it to debug a stuck receiver — errorPreview on failed attempts shows the first 200 chars of the response body.

MINIMAL_UI

Webhook configuration is disabled under MINIMAL_UI=true: the Settings tab is hidden, and POST / PATCH / DELETE / /test routes return 403 minimal_ui_disabled. The GET routes stay available so an operator can still audit what's wired up, and event delivery for already-configured webhooks still fires.

The rationale: webhooks let the server make arbitrary HTTPS calls to user-supplied URLs. Under MINIMAL_UI (locked-down deploys), only the operator should configure that — via direct file edits to ${FORGE_DATA_DIR}/webhooks.json or via env-driven config management.

Storage

File Purpose Mode
webhooks.json Webhook configs (URLs, events, scope, secrets, headers, TLS flag) 0600 — contains HMAC secrets
webhook-deliveries.json Rolling delivery history (cap 100 / webhook) 0600

Atomic-write + in-process lock pattern (see project-manager.ts for the same posture). Single-tenant / single-process assumption — cross-process safety would need a real file lock.

REST surface

Method + path Purpose
GET /api/v1/webhooks[?projectId=…] List webhooks. Optional filter returns global ∪ per-project for one project.
POST /api/v1/webhooks Create. Validates HTTPS-only URL, non-empty event subscription, known event types.
PATCH /api/v1/webhooks/:id Partial update. Sentinel-in-header semantics described above.
DELETE /api/v1/webhooks/:id Delete + prune deliveries.
POST /api/v1/webhooks/:id/test Fire a synthetic webhook.test event at the target, bypassing the event/scope filter — useful for verifying URL + signature wiring before waiting for a real event.
GET /api/v1/webhooks/:id/deliveries Recent attempts (newest first), capped at 100 per webhook.

Mutation routes are gated by MINIMAL_UI; the GET routes are not.

Full schemas at /api/docs.

Receiver examples

Python (FastAPI)

import hmac, hashlib, os
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()

@app.post("/pi-forge")
async def receive(
    request: Request,
    x_pi_forge_signature: str | None = Header(None),
    x_pi_forge_event: str | None = Header(None),
):
    body = await request.body()
    if x_pi_forge_signature is None:
        raise HTTPException(401, "missing signature")
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, x_pi_forge_signature):
        raise HTTPException(401, "bad signature")
    payload = await request.json()
    # …handle by payload["event"] ...
    return {"ok": True}

Node (Express)

import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.WEBHOOK_SECRET;

app.post("/pi-forge", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("X-Pi-Forge-Signature");
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(req.body).digest("hex");
  if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).end("bad signature");
  }
  const payload = JSON.parse(req.body.toString());
  // …handle by payload.event ...
  res.json({ ok: true });
});

Troubleshooting

Deliveries show failed with no detail. Check the receiver's TLS chain — pi-forge requires a valid cert by default. If the target is internal with a self-signed cert or a private CA, flip the per-webhook Allow self-signed / invalid TLS certificate checkbox.

unsupported_protocol on CREATE. The URL must be HTTPS. Pi-forge intentionally refuses plain HTTP even when insecureTls is set — the flag relaxes cert validation, not the scheme.

The test event arrives but real events don't. Check the event subscription on the webhook (events array) and the scope. A per-project webhook only fires for events whose projectId matches.

Header value lost after editing. If you PATCHed without including the headers field at all, the server leaves stored headers alone (the route can't distinguish "omitted" from "cleared" in JSON). If you PATCHed with the field present but a value set to the literal ***REDACTED*** sentinel, that specifically means "keep the existing value" — typing a new value replaces it.

MINIMAL_UI disable surprised me. The Webhooks Settings tab hides under MINIMAL_UI and POST/PATCH/DELETE routes 403. Already- configured webhooks still fire — disable applies only to config changes. To reach the config under MINIMAL_UI, edit ${FORGE_DATA_DIR}/webhooks.json directly (mode 0600) and restart.

See also