Webhook reference
Exact notification routes, payload fields, HTTP outcomes, and receiver formats. Use the task guides for a first send or question; use this chapter when wiring a sender or callback receiver.
Notification routes
| Route | Purpose |
|---|---|
| POST /hooks/:token | Send a notification. |
| GET /hooks/:token/events/:eventId | Read the state of an interactive response. |
| POST /hooks/:token/events/:eventId/cancel | Withdraw a pending interactive response. |
| POST /hooks/:token/events/:eventId/withdraw | Request removal of a delivered notification. |
All webhook writes use JSON with Content-Type: application/json. The token identifies the owning service. Invalid JSON fields return 400 and an issues array; a token that does not resolve returns 404. Keep event IDs from successful sends for the event-specific routes.
Notification fields
| Field | Type | Description |
|---|---|---|
| body | string, required | Trimmed nonempty message: at most 8,000 UTF-16 code units and 16,384 UTF-8 bytes. Including response reduces the code-unit limit to 2,000. |
| title | string | Optional sender title for this message; maximum 80 characters. Otherwise use the service title. |
| imageUrl | string | Optional avatar, maximum 2,048 characters. Requires public HTTPS; local names and loopback, private, or link-local addresses are invalid. |
| url | string | Tap destination, maximum 2,048 characters. Accepts web links, universal links, documented app schemes, and iOS Shortcuts URLs. |
| deviceIds | string[] | Select 1–50 device IDs instead of the default active-device fanout. Exclusive with group and oncall. |
| group | string | Workplace device-group ID (dgrp_…). Requires team membership and group targeting permission. Exclusive with deviceIds and oncall. |
| oncall | string | Workplace rotation ID (ocg_…). Creates a page with a body up to 2,000 characters; exclusive with deviceIds, group, and response. Families cannot page. |
| response | object | Question configuration: approval, yes/no, or typed answer. Exclusive with oncall and appId. |
| project | string | Inbox project name, maximum 80 characters. The first matching send creates the project if capacity permits. |
| summary | string | Banner/list digest, maximum 500 characters. The full message remains in the inbox. |
| bodyFormat | enum | Stored format marker: text (default) or markdown. V1 clients still display plain text. |
| appId | string | Registered hosted-app ID (app_…) available to the sender. Tap opens that app; an optional url must match its origin. Exclusive with response. |
HTTP results
{
"ok": true,
"eventId": "evt_example_pickup",
"delivered": 1
}| Field | Type | Description |
|---|---|---|
| 200 | ok | Accepted, or an idempotent replay. |
| 202 | ok | An identical request is still processing. |
| 400 | error | Invalid payload, key, or device selection. |
| 404 | error | Unknown webhook token. |
| 409 | error | Idempotency key reused with a new payload. |
| 429 | error | Rate limit exceeded. |
| 502 | error | All targeted push requests failed with the configured providers. |
Provider diagnostics belong in the dashboard activity log because raw errors may contain device push credentials. Inspect that log when a provider rejects a target; the caller receives the public status shape instead of sensitive provider output.
Per-question callbacks
A per-question callback receives answered outcomes as JSON with user agent Notify-Callbacks/1. A classic { url, token } callback uses a public HTTPS destination and Authorization: Bearer <callback.token>. A connector { target: { connectorId, route, path } } resolves the active clawchest.com address when delivery runs. Both carry Notify-Timestamp and Notify-Signature: v1=<hex> over {timestamp}.{rawBody}. Redirects are refused and each request times out after 10 seconds.
{
"type": "notification.response",
"interactionId": "int_example_pickup",
"actionDigest": "<the stored interaction actionDigest>",
"eventId": "evt_example_pickup",
"correlationId": "pickup-42",
"kind": "approval",
"status": "approved",
"action": "approve",
"text": null,
"respondedAt": "2026-10-11T18:06:52.000Z"
}The callback’s kind maps a text request to reply; approval and yes_no keep their names.
Correlate and deduplicate answers using interactionId. The existing eventId and correlationId remain available for service notifications, but can be null for agent-created asks. actionDigest identifies the exact stored Notify prompt; it is not proof that an external action executed. Accept additional JSON fields when reading callbacks. The callback tries immediately and, on non-2xx failure, again after 30 seconds, 2 minutes, 10 minutes, and 1 hour. Five failed attempts exhaust delivery. Store an outcome durably before returning 2xx.
Account reply webhooks
Use an account reply webhook when several tools should report interaction outcomes to one receiver. It subscribes to interaction.answered, interaction.expired, and/or interaction.canceled. Create a named HTTPS endpoint in the dashboard and enable it. Use for account replies makes it the single account default, including service-free MCP/API prompts. API and MCP updates use isDefault: true; false clears that endpoint’s default status. Selecting a new default replaces the old one.
For each outcome, Notify considers the enabled account default, the interaction’s explicit webhookId, and the service’s current defaultReplyWebhookId. Each distinct subscribed endpoint receives one durable delivery record; repeated selection of the same endpoint does not duplicate that record. Network retries may still reach a receiver more than once. A service’s selected webhook must have the same owning account.
text field along with delivery/signature headers.webhookId to each question.••••••••; either preserve saved headers or replace the entire list with actual values, never the mask.interaction.answered sample with test: true (202 means queued, not delivered). Inspect up to 50 recent deliveries, including latency, response code, and saved attempt details. Older deliveries may lack per-attempt history.Manage these endpoints with MCP webhooks_create, webhooks_list, webhooks_get, webhooks_update, webhooks_delete, webhooks_test, and webhooks_rotate_secret, or the dashboard /api/reply-webhooks routes. Create and rotate return the signing secret once. Get includes the last 50 deliveries. A delivery’s interactionId links it to the originating ask; test deliveries use null. Older servers may omit that field. An account can have up to 25 endpoints. Agent endpoint reads require services:read; writes require services:write.
Before parsing a request, verify X-Notify-Signature: t=<unix>,v1=<hex>. Sign the timestamp, a dot, and the unchanged body bytes with HMAC-SHA256 and the endpoint’s signing secret. Reject future timestamps and timestamps older than five minutes, then compare digests in constant time. Parsing and reserializing JSON cannot preserve this signature input.
Node receiver helper; supply the original body bytes as a Buffer:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyNotify(rawBody, signature, secret) {
if (!Buffer.isBuffer(rawBody) || typeof signature !== "string") return false;
const match = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec(signature);
if (!match) return false;
const [, timestamp, hex] = match;
const now = Math.floor(Date.now() / 1000);
const age = now - Number(timestamp);
if (!Number.isSafeInteger(Number(timestamp)) || age < 0 || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(hex, "hex"));
}
// Use a raw-body middleware before any JSON parser:
// verifyNotify(req.body, req.get("X-Notify-Signature"), process.env.NOTIFY_WEBHOOK_SECRET)
// Only after verification: JSON.parse(req.body.toString("utf8"))Python receiver helper; supply the original body bytes as bytes:
import hashlib
import hmac
import re
import time
def verify_notify(raw_body: bytes, signature: str, secret: str) -> bool:
if not isinstance(raw_body, bytes) or not isinstance(signature, str):
return False
match = re.fullmatch(r"t=([0-9]+),v1=([a-f0-9]{64})", signature)
if not match:
return False
timestamp, received = match.groups()
age = int(time.time()) - int(timestamp)
if age < 0 or age > 300:
return False
signed = timestamp.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Read the raw body before calling the framework's JSON parser.
# After verification, decode with json.loads(raw_body).X-Notify-Delivery-Id identifies a delivery across retries; commit its record together with the side effect. Already processed IDs should return 2xx. Retries preserve the ID and body but refresh the signature timestamp. Tests and separate lifecycle outcomes receive separate IDs.Reply webhooks try immediately and then after 1, 5, 15, and 40 minutes. Timeouts and 5xx responses retry; 4xx responses and redirects are final failures. Any 2xx acknowledges delivery. Disabling stops new deliveries. These endpoints can coexist with classic callbacks, but their payloads, signature headers, and retry schedules differ.
Access and consent
Keep the connection’s permissions as small as the work requires. Agent grants authorize API operations; human actions and native-device permissions remain separate decisions.
Activity fields and layouts
Field limits and platform-specific appearance for progress activities and interactive iOS cards. The lifecycle guide is [Track work](#activity-api).