Appearance
Webhooks
Forge POSTs signed envelopes when ActivityEvents fire. Delivery is durable and retried; receivers verify HMAC and acknowledge with 2xx. Subscriptions are scoped to a workspace, and every delivery is recorded so admins can replay or dead-letter from the UI.
Webhooks are wake signals, not work state
For agent-routed events (AGENT_ASSIGNED, COMMENT_CREATED with mentions, ISSUE_PRIORITY_CHANGED, CHAT_MESSAGE_POSTED, watcher fan-out), the canonical work record is an AgentRun (issue subjects) or ChatMessage (chat subjects) created in the same transaction as the ActivityEvent. A successful webhook delivery is a low-latency wake; a missed wake is recoverable by the agent calling mcp_forge_agent_inbox_list. Receivers should always ack via mcp_forge_agent_inbox_ack and read mcp_forge_agent_context_bundle rather than trusting the wake payload as the full task spec. See /agents/hermes.html.
The model
A Webhook row on a workspace declares where Forge should POST and which events you want.
prisma
model Webhook {
id String @id @default(cuid())
workspaceId String
url String
secret String
events EventKind[]
active Boolean @default(true)
createdAt DateTime @default(now())
@@unique([workspaceId, url])
}There is one row per (workspaceId, url) pair — re-registering the same URL updates the existing subscription rather than creating a duplicate. events is a subset of EventKind; an empty array means "all kinds the workspace emits". active = false pauses delivery without losing the row.
TIP
The secret is generated server-side at registration. Treat it as a credential — it is the only thing standing between your endpoint and a spoofed payload.
Event kinds
For a full list of event kinds see /reference/events.html. The agent-routed events that also trigger webhook delivery to the agent's webhookUrl are:
| EventKind | When it fires | Agent dispatch |
|---|---|---|
AGENT_ASSIGNED | Dispatcher or manual assignment | Yes — routed to assigned agent. This is a work-start signal; no extra @mention is required. Payload embeds issueSnapshot: { id, number, title, priority, statusId, projectId, labelNames } for quick framing, but receivers should call the context-bundle/read API before acting so they see recent comments and attachments. When Workspace.startedStatusId is set and the issue was eligible (BACKLOG/TODO category), payload also embeds autoTransitionedTo: <statusId> to mark a server-driven IN_PROGRESS transition; the embedded issueSnapshot.statusId reflects the post-transition state. |
ISSUE_QUEUED | queued flips true | Yes — routed to assigned agent (if any) |
COMMENT_CREATED | New comment with agent @mention, or watched issue event | Yes — per mentioned/watching agent. Plain comments do not wake every assignee by default. |
ISSUE_PRIORITY_CHANGED | Priority → HIGH or URGENT | Yes — routed to assigned agent |
CHAT_MESSAGE_POSTED | User sends a chat message | Yes — routed to addressed agent (USER role only; agent replies do not loop back) |
Chat dispatch (branch d)
When a CHAT_MESSAGE_POSTED event fires with subjectType = "chat-thread" and payload.role = "USER", recordChange in src/server/audit.ts enqueues a WebhookDelivery to the per-agent synthetic shim agent:dispatch:{agentId}. The worker resolves this to the agent's real webhookUrl at delivery time.
Agent replies (role = "AGENT") do not trigger dispatch — the event is still emitted and fans out to SSE subscribers, but no outbound webhook delivery is enqueued for the agent.
Outbound envelope
Every delivery is a single POST with a JSON body and three headers:
http
POST <url>
Content-Type: application/json
x-forge-timestamp: 1745683200
x-forge-signature: 9f3a1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e
x-webhook-signature: 6e7f8091a2b3c4d5e9f3a1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f708192a3b4c5d
{
"id": "cle9k4z2j0001qg9k7m4n8p2x",
"kind": "ISSUE_CREATED",
"subjectType": "issue",
"subjectId": "cle9k4z2j0002qg9k4f7r2x1d",
"payload": {
"issue": {
"id": "cle9k4z2j0002qg9k4f7r2x1d",
"key": "AXI-42",
"title": "Investigate flaky e2e",
"priority": "HIGH",
"statusId": "cle9k4z2j0003qg9k7n4p2x"
}
},
"createdAt": "2026-04-26T18:00:00.000Z"
}Why two signatures
x-forge-signature is replay-protected: it covers the timestamp prefix, so an attacker cannot capture an envelope and re-deliver it later (you should reject timestamps older than 300 seconds). x-webhook-signature is body-only and works with generic validators — Hermes' inbound adapter, GitHub-style verifiers, and most off-the-shelf middleware accept this shape directly.
Both are HMAC-SHA256 over the same per-subscription secret. Verify whichever is convenient for your stack — if you can verify both, do.
x-forge-signature = hex(hmac_sha256(secret, `${ts}.${rawBody}`))
x-webhook-signature = hex(hmac_sha256(secret, rawBody))WARNING
Verify against the raw request body. Re-serializing the parsed JSON will re-order keys and your HMAC will not match. Most frameworks expose this as req.rawBody or via a buffer middleware before JSON parsing.
Verifying in TypeScript
ts
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
const MAX_SKEW_SECONDS = 300;
export function verifyForgeWebhook(
secret: string,
rawBody: Buffer,
headers: IncomingMessage["headers"],
): { ok: true } | { ok: false; reason: string } {
const ts = Number(headers["x-forge-timestamp"]);
const sigTs = String(headers["x-forge-signature"] ?? "");
const sigBody = String(headers["x-webhook-signature"] ?? "");
if (!Number.isFinite(ts)) return { ok: false, reason: "missing timestamp" };
if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) {
return { ok: false, reason: "stale timestamp" };
}
const expectedTs = createHmac("sha256", secret)
.update(`${ts}.${rawBody.toString("utf8")}`)
.digest("hex");
const expectedBody = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const tsOk =
sigTs.length === expectedTs.length &&
timingSafeEqual(Buffer.from(sigTs, "hex"), Buffer.from(expectedTs, "hex"));
const bodyOk =
sigBody.length === expectedBody.length &&
timingSafeEqual(
Buffer.from(sigBody, "hex"),
Buffer.from(expectedBody, "hex"),
);
if (!tsOk && !bodyOk) return { ok: false, reason: "bad signature" };
return { ok: true };
}Why timingSafeEqual?
String === short-circuits on the first mismatched byte. An attacker measuring response latency can recover a valid HMAC byte-by-byte. Constant-time comparison closes that side channel.
Delivery durability
Every fired event creates a WebhookDelivery row before the network call:
| Field | Purpose |
|---|---|
status | PENDING / SUCCESS / FAILED / DEAD_LETTER |
attempt | Increments on each retry |
responseStatus | Last HTTP status we saw |
responseBody | First N bytes of the response (truncated) |
nextAttemptAt | When the worker will pick it up next |
The BullMQ worker (src/server/worker.ts) drains the queue with exponential backoff. Non-2xx responses, network errors, and timeouts all schedule a retry. After retries are exhausted the row flips to DEAD_LETTER and stops moving on its own.
INFO
The publish step on the mutation path is best-effort and never blocks the write. Durability lives entirely in WebhookDelivery rows — if Redis is down the row is still created and the worker picks it up when Redis recovers.
Dead-letter admin
/w/<slug>/settings/admin lists failed deliveries with an audit table — timestamp, kind, target URL, last response, attempt count. Admins can:
- Retry one — re-queues a single row immediately.
- Bulk retry — re-queues all
DEAD_LETTERrows in the current filter. - Inspect — expand a row to see request/response bodies for debugging.
Bulk retries write an AuditLog entry naming the operator and the row count.
The agent-dispatch shim
Webhook URLs that begin with agent:dispatch or agent:dispatch:{agentId} are not real URLs. They are synthetic markers that the worker resolves to the target agent's real webhookUrl at delivery time:
agent:dispatch:{agentId}— deliver to that specific agent.agent:dispatch— workspace-shared shim; the worker reads the event payload'sagentIdand looks up the row at send time.
This is what powers push-dispatch. The synthetic row decouples the event from agent-row mutations: changing an agent's webhookUrl does not require rewriting old WebhookDelivery rows or pending jobs. See /agents/hermes.html for the integration story end to end.
TIP
agent.webhookHealth (tRPC) reads the synthetic shims by URL prefix — that is how the agent ops dashboard surfaces per-agent delivery health.
Receiver guidance
A correct receiver does five things:
- Read the raw body before any JSON parsing middleware mutates it.
- Verify HMAC using
timingSafeEqual. Reject mismatches with401. - Reject stale timestamps older than 300 seconds when verifying
x-forge-signature. - Respond fast. Acknowledge with
200(or202) and process async. Forge's HTTP timeout is short; a slow handler will be retried even if it eventually succeeds. - Idempotency by
id. Retries can deliver the same event twice — keep a short-lived seen-id cache (Redis SETEX,WHERE NOT EXISTS, etc.) and no-op duplicates.
WARNING
Returning 2xx from a handler that has not yet committed its work is a common bug — Forge will mark the delivery SUCCESS and you will silently drop events on a crash. Either commit before responding, or write to a durable inbox table and ack.
Cross-references
- /reference/events.html — every
EventKindand the payload shapes. - /agents/hermes.html — how Hermes consumes the
agent:dispatchshim. - /concepts/activity-and-audit.html — the audit + event flow that emits these envelopes.
- /automation/api-keys.html — keys for the SSE alternative to webhooks.