Webhooks
A workspace can send a signed JSON POST to your server whenever something happens to its issues. Check the signature, skip deliveries you've already handled, and answer 2xx quickly:
export default {
async fetch(req: Request, env: { BELLHOP_WEBHOOK_SECRET: string }) {
const body = await req.text() // the raw body: the signature covers these exact bytes
const ok = await verifyBellhop(env.BELLHOP_WEBHOOK_SECRET, req.headers.get("Bellhop-Signature"), body)
if (!ok) return new Response("Bad signature", { status: 401 })
const event = JSON.parse(body)
if (await seen(event.id)) return new Response(null, { status: 204 })
if (event.type === "issue.opened") await triage(event.issue, event.metadata ?? [])
return new Response(null, { status: 204 })
},
}
verifyBellhop is under Verify the signature; seen and triage are yours.
Set up
Workspace admins set the webhook in Settings → Webhook:
- Payload URL: an
httpsURL on a public domain name. IP addresses,localhost,.local,.internal,.localdomain,.home.arpaand Bellhop's own hosts aren't allowed, and neither isuser:password@. - Events: which events to send. A new webhook gets the defaults marked below.
- Signing secret: reveal, copy or rotate it. Rotating takes effect at once, so update your server first or accept both for a moment.
- Send test sends a
pingevent, which is always delivered whatever you've selected. - Recent deliveries lists the last 50 with their status, response code and attempts, and can Redeliver any of them.
Agents with the admin role can do the same with the get_webhook, set_webhook, test_webhook, redeliver_webhook and rotate_webhook_secret tools.
Events
| Event | Default | When |
|---|---|---|
issue.escalated |
No | A request was handed to a person: by an agent, the requester, the team or routing. escalation says why, who and which group; it fires again if it passes to the next member. |
issue.handed_back |
No | A team member handed the request back to agents. |
ping |
Always | Send test was pressed. Always delivered, whatever is selected. |
issue.opened |
Yes | An issue was opened. |
issue.updated |
Yes | Anything about an issue changed. changes lists what. |
comment.created |
Yes | A comment or internal note was posted, not by an agent. |
mention.created |
Yes | Someone @mentioned a team member or an agent. |
issue.closed |
No | An issue was closed. issue.close_reason says why. |
issue.reopened |
No | A closed issue was reopened. |
issue.labeled |
No | A label was added. The label is in label. |
issue.unlabeled |
No | A label was removed. The label is in label. |
issue.assigned |
No | The assignee changed. issue.assignee is null when it was cleared. |
issue.deleted |
No | An issue was deleted. |
issue.moved |
No | An issue was moved to another workspace. moved_to says where. |
comment.edited |
No | A comment was edited, not by an agent. |
comment.deleted |
No | A comment was deleted. |
verification.completed |
No | An email verification on a request finished. verification says whether it was verified, failed or expired. |
reply.held |
No | An agent's public reply was held for review (autonomy suggest). The reply is in comment. |
approval.requested |
No | An agent asked for an approval. It's in approval. |
approval.decided |
No | A team member approved or rejected an approval. It's in approval, never with its token. |
issue.updated fires for every change and says which fields changed in changes: for example ["status", "close_reason"], ["labels"], ["assignee"] or ["comment.edited"]. The specific events fire alongside it if you've selected them. Comments written by agents don't send comment.created or comment.edited, so an agent that listens to webhooks never wakes itself.
Payload
Every delivery is one JSON object:
| Field | Present | Description |
|---|---|---|
id |
always | The delivery id, the same as the Bellhop-Delivery header. A redelivery has a new one. |
type |
always | The event. |
created_at |
always | When the event happened. A redelivery keeps the original time. |
app |
always | The workspace: id, slug, name. |
actor |
always | Who did it: a member, a guest or an agent, with their tier if they have one. |
mcp_url |
always | The MCP endpoint, for agents that act on the event. |
issue |
all but ping |
The issue as it is after the change, with its url and the reporter's language in reporter_locale. |
comment |
comment events, mention.created, reply.held |
The comment, including internal notes (internal: true). For reply.held, the held agent reply, with held.at. |
changes |
issue.updated |
Which fields changed. |
label |
issue.labeled, issue.unlabeled |
The label. |
mentioned |
mention.created |
Who was mentioned: a team member or an agent. |
moved_to |
issue.moved |
Where it went: slug, number, url. |
metadata |
issue.opened, comment.created |
Report metadata attached to that post: specs, context, logs and fingerprint matches. |
contact |
help desk requests | The requester's contact: id, name, verified email, display-only unverified_email, your app's external_id, verified_via (host or email), created_at and last_seen_at. |
verification |
verification.completed |
The email verification: id, email, state (verified, failed or expired), requested_by, created_at and verified_at. The code is never included. |
approval |
approval.requested, approval.decided |
The approval: action, summary, details, state (pending, then approved or rejected), requested_by, decided_by and note. Never its token; see Running support agents. |
escalation |
issue.escalated |
Why and how the request went to a person: reason, source (agent, requester, team or routing), by, at, group, waiting (hours, nobody_available or null) and respond_by. The new assignee is in issue.assignee. |
redelivery_of |
redeliveries | The id of the delivery this repeats. |
Webhooks are for the team, so payloads include private issues, internal notes and reporter tiers. Keep them out of anything public.
An issue.opened delivery for a report with metadata:
{
"actor": { "type": "member", "id": "085329df-a2d5-42bf-a4ad-a1b15c742067", "name": "ryan", "avatar_url": null, "role": "owner", "tier": null },
"issue": {
"id": "d667e97e-8996-4602-b6b2-993e4dd2b4b0",
"number": 80,
"title": "Crash on startup: config.json is a directory",
"status": "open",
"close_reason": null,
"created_at": "2026-11-04T16:49:54.075Z",
"updated_at": "2026-11-04T16:49:54.075Z",
"comment_count": 0,
"votes": 0,
"viewer_voted": false,
"pinned": false,
"locked": false,
"private": false,
"hidden": null,
"author": { "type": "member", "id": "085329df-a2d5-42bf-a4ad-a1b15c742067", "name": "ryan", "avatar_url": null, "role": "owner", "tier": null },
"assignee": null,
"activity": null,
"labels": [],
"reporter_locale": "en",
"url": "https://acme.blhp.app/issues/80"
},
"app": { "id": "2377e83c-1cb3-4bde-b1de-f10ee8912acb", "slug": "acme", "name": "Acme" },
"mcp_url": "https://blhp.app/mcp",
"type": "issue.opened",
"metadata": [
{
"id": "md_947e87d776e850f971022a808ecc8bc9a4934f2e5141ced515fd8249c5a67dba",
"title": "Crash on startup: config.json is a directory",
"message": "EISDIR: illegal operation on a directory, read\n at loadConfig (config.ts:88)",
"source": { "name": "Acme Desktop", "version": "2.4.1" },
"system": { "OS": "macOS 15.1", "CPU": "Apple M3", "Memory": "16 GB" },
"context": { "session": "ses_7f3a91c2", "workspace": "~/dev/acme" },
"fingerprint": "eisdir-loadconfig-88",
"created_at": "2026-11-04T16:49:54.048Z",
"expires_at": null,
"logs": [
{
"id": "ml_77408702-3cbe-4aa7-b1c3-f2002b3ccdbb",
"name": "main.log",
"size": 57,
"kind": "file",
"mime": "text/plain",
"url": "/media/ml_77408702-3cbe-4aa7-b1c3-f2002b3ccdbb/main.log?metadata=md_947e87d776e850f971022a808ecc8bc9a4934f2e5141ced515fd8249c5a67dba"
}
],
"matches": [],
"attached_to": { "number": 80, "comment_id": null }
}
],
"id": "2cf308bf-5527-49b2-a935-587dfcaf11cc",
"created_at": "2026-11-04T16:49:54.083Z"
}
Log urls are paths on the workspace host and need a team member's session. An agent reads logs with the get_attachment tool instead.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
Bellhop-Event |
The event type, such as issue.opened. |
Bellhop-Delivery |
The delivery id, the same as the payload's id. |
Bellhop-Signature |
t=<unix seconds>,v1=<signature>, where the signature is the lowercase hex HMAC-SHA256 of <t>.<raw body> keyed with the signing secret. |
Verify the signature
Compute the HMAC over the timestamp, a dot and the raw body exactly as received, compare it in constant time, and reject old timestamps so a captured request can't be replayed later.
TypeScript, with the Web Crypto API (Workers, Node 18+, Bun, Deno):
export async function verifyBellhop(secret: string, header: string | null, body: string, toleranceSeconds = 300) {
const parts = new Map((header ?? "").split(",").map((p) => p.split("=", 2) as [string, string]))
const t = Number(parts.get("t")), v1 = parts.get("v1") ?? ""
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !/^[0-9a-f]{64}$/.test(v1)) return false
const enc = new TextEncoder()
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"])
const signature = Uint8Array.from(v1.match(/../g)!, (h) => parseInt(h, 16))
return crypto.subtle.verify("HMAC", key, signature, enc.encode(`${t}.${body}`))
}
Python 3, with the standard library:
import hashlib
import hmac
import time
def verify_bellhop(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Use the raw body, not JSON you've parsed and serialized again: any change in spacing or key order breaks the signature.
Delivery, retries and redelivery
- Bellhop waits up to 10 seconds for an answer. A
2xxmarks the delivery as delivered. Do slow work after you've answered. - A non-
2xxanswer, a timeout or a network error is retried up to 6 more times, after 1, 2, 4, 8, 16 and 32 seconds. After that the delivery is marked failed. - Redirects aren't followed. A
3xxfails the delivery at once. - Redeliver in Settings sends the same payload again with a new
idandredelivery_ofset to the original. It goes to the URL configured now, and only if the webhook still subscribes to that event. - Deliveries can arrive more than once and out of order. Use
idto skip ones you've handled, andcreated_ator the issue'supdated_atto ignore stale ones. - Deleting an issue or a comment also deletes stored deliveries that contain it, so they can't be redelivered.