Webhooks
Frozen surface (dashboard UI)
The dedicated webhooks page in the cloud dashboard is currently frozen by an explicit product decision (2026-07-04): the sidebar entry is hidden and /webhooks redirects to /dashboard. The webhook API and delivery pipeline described below remain live and retained, but no new webhook feature work is planned until the freeze is lifted. Manage webhooks via the REST API surface documented here.
Cloud Only
Webhooks are available in the Cloud version only.
Webhooks let ADHDev Cloud push machine and agent events to your own HTTP endpoint.
Important current behavior:
- the webhook API is live
- the dedicated
/webhooksdashboard page is not currently a stable standard navigation surface - use the REST API reference as the canonical contract for payloads, events, and delivery-history shapes
- webhook create / toggle / delete / test actions are recent-login protected cloud account operations and require a fresh dashboard session JWT, not an API key
Creating a Webhook
Create the webhook from a currently signed-in cloud dashboard session. The underlying request body is:
{
"url": "https://example.com/hooks/adhdev",
"events": ["agent:status", "agent:approve_request"]
}If you call the mutating webhook endpoints from a stale browser session or with an API key, the server returns 403 RECENT_LOGIN_REQUIRED instead of provisioning the hook.
The create response returns:
- the webhook record
- a
secretshown once for signature verification
events is an allow-list, not a free-form subscription: only the names below (plus "*") are accepted — anything else is silently dropped. Use ["*"] to subscribe to every event the server dispatches, including the finer-grained ones that cannot be subscribed to individually.
Events
Individually subscribable (accepted in events):
| Event | Trigger |
|---|---|
daemon:connect | Daemon connected to the server |
daemon:disconnect | Daemon connection lost |
agent:status | Agent status changed (throttled, max 1 per 10s per daemon) |
agent:approve_request | Reserved — allow-listed but not currently dispatched by the server |
Dispatched to the server but only delivered to webhooks subscribed with "*" (not individually subscribable):
| Event | Trigger |
|---|---|
agent:generating_started | Agent started generating a response |
agent:generating_completed | Agent finished generating |
agent:waiting_approval | Agent is waiting for approval |
agent:waiting_choice | Agent is waiting on a multiple-choice prompt |
agent:stopped | Agent session stopped |
monitor:no_progress | Agent appears stalled with no progress (legacy alias: monitor:long_generating) |
webhook:test | Explicit test delivery via POST /webhooks/{id}/test — bypasses subscription filtering entirely, delivered only to the one webhook being tested |
Payload Format
Every delivery is a JSON body with three top-level fields: event, payload, and timestamp (when the server sent the delivery, epoch milliseconds). For the agent and monitor events (agent:* except agent:status, and monitor:no_progress) the payload looks like this:
{
"event": "agent:generating_completed",
"payload": {
"daemonId": "8e1f4c2a9b7d3e5f6a0c1b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f",
"daemonType": "adhdev-daemon",
"event": "agent:generating_completed",
"timestamp": 1714000000000,
"targetSessionId": "9b2d7c4e-1f3a-4b5c-8d6e-0a1b2c3d4e5f",
"providerType": "claude-cli",
"providerSessionId": "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f",
"workspaceName": "/home/dev/projects/myproject",
"duration": 42,
"surfaceHidden": false,
"muted": false
},
"timestamp": 1714000000215
}| Field | When present |
|---|---|
daemonId | Always — the daemon connection that reported the event |
event, timestamp | Always — the event name and when the daemon observed it (epoch milliseconds) |
targetSessionId | When the event concerns one agent session — always for agent:generating_completed and agent:stopped |
providerType | When the daemon can resolve the session. The agent provider, for example claude-cli or cursor. Absent otherwise — it never carries the daemon kind |
daemonType | Always. The kind of daemon that sent the event, currently adhdev-daemon |
providerSessionId | When the provider exposes its own conversation id |
workspaceName | When the session has a workspace — the working directory of a CLI/ACP agent, or the IDE workspace |
duration | agent:generating_completed only, in whole seconds, when the daemon saw the turn start |
elapsedSec | monitor:no_progress — seconds without visible progress |
modalMessage, modalButtons | agent:waiting_approval / agent:waiting_choice — the approval prompt text and its button labels |
surfaceHidden, muted | When the daemon hosts the session — whether it is hidden or muted on the dashboard |
daemon:connect, daemon:disconnect, agent:status, and webhook:test carry their own, different payloads. Apart from the approval prompt text in modalMessage, the payload never includes chat messages, chat titles, or agent responses.
Signature Verification
Each webhook request includes:
X-ADHDev-SignatureX-ADHDev-Event
The signature format is:
X-ADHDev-Signature: t=1714000000000,v1=a3f9c2b1...Verify it against the raw request body.
import crypto from 'crypto'
function verifySignature(body, signature, secret) {
const parts = Object.fromEntries(signature.split(',').map((p) => p.split('=')))
const timestamp = parts.t
const received = parts.v1
const payload = `${timestamp}.${body}`
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(received),
Buffer.from(expected)
)
}
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-adhdev-signature']
const body = req.body.toString('utf8')
if (!verifySignature(body, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(body)
// handle event...
res.sendStatus(200)
})import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in signature.split(','))
timestamp = parts.get('t', '')
received = parts.get('v1', '')
payload = f"{timestamp}.{body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
sig = request.headers.get('X-ADHDev-Signature', '')
if not verify_signature(request.data, sig, WEBHOOK_SECRET):
return 'Invalid signature', 401
event = request.get_json()
# handle event...
return '', 200Your webhook secret is shown once when you create the webhook — store it securely.
Management Endpoints
GET /api/v1/webhooks
POST /api/v1/webhooks
PATCH /api/v1/webhooks/{webhookId}
DELETE /api/v1/webhooks/{webhookId}
GET /api/v1/webhooks/{webhookId}/deliveries?limit=20
POST /api/v1/webhooks/{webhookId}/testGET endpoints are the safest automation surface. The mutating endpoints (POST, PATCH, DELETE, test) are recent-login protected dashboard-session operations.
Retry Policy
Failed deliveries are retried up to 3 times with exponential backoff.
Plan Limits
| Plan | Max Webhooks |
|---|---|
| Free | 2 |
| Pro | 10 |
| Ultra | 50 |
| Enterprise | Unlimited |
