# 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.

HTML: https://notify.belweave.ai/docs/notification-api

## 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

```json
{
  "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.

```json
{
  "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.

> This callback only reports a person’s answer. It does not report expired or canceled questions. Poll the event route for those states, or use an account reply webhook for interaction lifecycle events.

## 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.

1. Select Generic HTTPS, grok, or Slack. New dashboard endpoints initially select answered, expired, and canceled outcomes; editing an existing endpoint preserves its selected outcomes. A grok routine supplies its webhook URL and Authorization header. Slack incoming webhooks receive a JSON `text` field along with delivery/signature headers.
2. For service-free MCP or API questions, enable Use for account replies on the endpoint and subscribe to the desired outcomes. This avoids attaching `webhookId` to each question.
3. Add receiver-specific custom headers if needed. Reserved security/transport headers cannot be replaced. Header values are encrypted and read back as `••••••••`; either preserve saved headers or replace the entire list with actual values, never the mask.
4. Store the one-time signing secret at the receiver. Rotation supplies a replacement and makes the old secret unusable; update the receiver accordingly.
5. Use Test & logs to queue an `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`:

```javascript
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`:

```python
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).
```

> Use durable deduplication after signature verification. `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.
