Send alerts to employee phones

Quickstart

Notify turns an HTTP request into a notification on an employee's iPhone. Create a service, then POST JSON to its secret webhook URL.

What Notify is

Anything that can send an HTTP request can reach employee phones: CI jobs, agents, cron scripts, monitors. Each service has its own name, avatar, and tap destination. Notify fills in any field you omit from those defaults.

There are two APIs, both authenticated by the same webhook token. The Notification API sends one-shot pushes and optional approval prompts. The Activity API drives a stateful Live Activity on the Lock Screen and in the Dynamic Island.

Create a service

  1. Sign in at notify.belweave.ai.
  2. Have employees register their iPhones with Notify for iPhone.
  3. Create a service in the dashboard and give it a title, avatar, and tap URL.
  4. Copy the secret webhook URL it returns.

Copy the webhook URL

The plaintext token is shown when the service is created and whenever you rotate it. Treat it as a credential: anyone holding it can notify your devices.

https://notify.belweave.ai/hooks/whk_your_token

An unknown or rotated token returns 404 with { "ok": false, "error": "Unknown webhook" }.

Send a notification

Only body is required. Everything else falls back to the service defaults.

curl -X POST https://notify.belweave.ai/hooks/whk_your_token \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: deploy-184-production' \
  -d '{
    "body": "Production deployed successfully.",
    "title": "GitHub",
    "imageUrl": "https://github.com/github.png",
    "url": "https://github.com/acme/app/actions"
  }'

Read the response

{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1
}

eventId identifies the event in the dashboard activity log and, for interactive notifications, is the handle used to read or cancel the pending response. delivered is the number of push requests accepted by Expo.

A request with no registered device still succeeds with delivered: 0 and a message field, so an unpaired phone never fails your build.

Notification API

One-shot pushes, delivered to every registered iPhone or to the devices you name. The webhook token in the URL is the only credential.

Endpoint

Notification API routes
RoutePurpose
POST /hooks/:tokenSend a notification.
GET /hooks/:token/events/:eventIdRead the state of an interactive response.
POST /hooks/:token/events/:eventId/cancelWithdraw a pending interactive response.
POST /hooks/:token/events/:eventId/withdrawRequest removal of a delivered notification.

Send Content-Type: application/json. An unrecognised token returns 404; a payload that fails validation returns 400 with an issues array describing each field.

Request payload

Notification request fields
FieldTypeDescription
bodystring, requiredNotification message, 1 to 8,000 characters (at most 16 KiB of UTF-8) after trimming. Interactive requests with response keep the 2,000-character limit.
titlestringSender-name override, up to 80 characters. Defaults to the service title.
imageUrlstringPublic HTTPS avatar URL, up to 2,048 characters. localhost, .local, loopback, link-local and private IP ranges are rejected.
urlstringWeb URL, universal link, app deep link, or Shortcuts URL opened when the notification is tapped. Up to 2,048 characters.
deviceIdsstring[], Pro1 to 50 device IDs from the dashboard. Omit to notify every active device. Cannot be combined with group.
groupstringDevice group ID (dgrp_…). Delivers to that org group's members and listed devices. The sender must belong to the group’s team. Cannot be combined with deviceIds or oncall.
responseobject, ProTurns the notification into an approval, yes/no, or text prompt.
projectstringProject display name, up to 80 characters. Files the notification into that project in the Notify app inbox, creating it on first use.
summarystringShort digest, up to 500 characters. Replaces the body in the push banner and list previews; the full body stays readable in the app.
bodyFormatenumtext or markdown. Stored metadata describing the body; omitted means text.
appIdstringA web app ID (app_…) on your account. Tapping opens that app in Notify, at url when given; url must then share the app's origin. Not combinable with response.

Projects and summaries

Send an optional project display name to group notifications in the Notify app inbox. Project identity is case-insensitive and Unicode-normalized within your account, so Acme App and acme app are the same project; the first spelling you send becomes the display name. Notifications without a project land in a shared Other bucket.

Long bodies stay intact in storage and in the app's notification detail, while the push banner and list rows show the summary when you provide one, or a bounded preview otherwise. Bodies render as plain text with tappable links; bodyFormat is recorded for future rendering and does not change V1 display.

Accounts hold up to 500 projects. Once the cap is reached, a request naming a new project still delivers — the notification is stored without a project and the response carries an explanatory message. Existing project names keep resolving normally.

Withdraw a notification

Keep the eventId returned when you send a notification, then use it to request removal of that notification from the account's registered iPhones.

curl -X POST \
  'https://notify.belweave.ai/hooks/whk_your_token/events/evt_Cxns2IdbF4H0TJYq/withdraw'
{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "status": "withdrawn",
  "accepted": 1
}

accepted means Expo accepted the silent removal command; it does not guarantee that iOS ran it. Background delivery is best effort and may be delayed or skipped, particularly after the user force-quits Notify. The same webhook token must own the event. Repeating a completed withdrawal is idempotent and does not send another command.

Deep links and Shortcuts

Set url per notification or as the service default. Notify opens it only after the recipient explicitly taps the notification; receiving a push does not launch an app or run background automation.

  • Use an https:// universal link when the destination app supports one. iOS opens the installed app and otherwise falls back to its website.
  • Use the destination app's documented custom scheme for app-only routes, such as your-app://incidents/INC-42. If no installed app handles the scheme, Notify remains open.
  • Percent-encode names, paths, and query values that contain spaces or reserved characters.
{
  "body": "Incident INC-42 needs attention.",
  "url": "your-app://incidents/INC-42"
}

To run a shortcut saved on the recipient's iPhone, use Apple's Shortcuts URL scheme. The shortcut name must match exactly. Set input=text and provide URL-encoded text, or set input=clipboard to pass the current clipboard.

{
  "body": "Production deployed. Tap to run the follow-up.",
  "url": "shortcuts://run-shortcut?name=Deployment%20Follow-up&input=text&text=production%20deployed"
}

iOS may require the device to be unlocked, and the shortcut can still show its own permission or confirmation prompts. Notify cannot run a shortcut merely because a notification arrived. Unsafe local or executable schemes such as javascript:, data:, file:, blob:, and about: are rejected.

Idempotency

Send an optional Idempotency-Key header of 1 to 200 characters. Keys are scoped to a single service.

  • Same key and payload: the original event is returned with idempotent: true.
  • Same key while the first request is still in flight: 202 Accepted.
  • Same key with a different payload: 409 Conflict.
  • Blank or over-length key: 400.

Device routing

By default a request fans out to every active iOS device on the account, most recently seen first. Free accounts are capped at one device, so extra phones are ignored until you upgrade.

Notify Pro can pass a non-empty deviceIds array to target specific iPhones. Copy the stable device IDs from the dashboard. IDs that do not belong to the account return 400 Invalid device selection; owned but inactive or non-iOS devices in the list are skipped silently. Org device groups use group instead and do not require device routing.

{
  "body": "The production deploy needs attention.",
  "deviceIds": ["dev_your_iphone_id"]
}

Sending deviceIds without device routing on your plan returns 402.

Rate limits

Plan limits
LimitFreePro
Requests per minute, per service60300
Requests per minute, per account3001,500
Notifications per month10,000100,000
Active devices1Unlimited

The per-minute counters use a rolling 60-second window and are shared across notifications, interactive responses, and Live Activity operations. A limited request returns 429 with a Retry-After: 60 header and retryAfterSeconds in the body. Exhausting the monthly allowance also returns 429, without a retry hint.

Response shape

{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1
}
Notification status codes
FieldTypeDescription
200okAccepted, or an idempotent replay.
202okAn identical request is still processing.
400errorInvalid payload, key, or device selection.
402errorThe payload uses a Notify Pro feature.
404errorUnknown webhook token.
409errorIdempotency key reused with a new payload.
429errorRate limit or monthly allowance exhausted.
502errorEvery push target was rejected by Expo.

Provider errors can embed a device push token, so they are recorded in the dashboard activity log rather than returned to the caller. Use that log to find devices that are no longer registered.

Interactive responses

Notify Pro can attach a fixed response type to any notification. Supported types are approval (Approve or Deny), yes_no (Yes or No), and text (a short free-form reply).

{
  "title": "Production deploy",
  "body": "Deploy commit 8e7fc2a?",
  "response": {
    "type": "approval",
    "expiresInSeconds": 900,
    "correlationId": "deploy-184",
    "callback": {
      "url": "https://ci.example.com/ntf-response",
      "token": "private-callback-token"
    }
  }
}
Interactive response fields
FieldTypeDescription
typestring, requiredapproval, yes_no, or text.
expiresInSecondsinteger30 to 86,400. Defaults to 900.
correlationIdstringYour own identifier, up to 100 characters. Echoed back on the callback.
callback.urlstringPublic HTTPS URL that receives the answer.
callback.tokenstring16 to 512 characters, sent back as a bearer token so you can verify Notify.

The response body gains a response object alongside the usual fields:

{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1,
  "response": { "status": "pending", "expiresAt": "2026-07-25T18:19:04.000Z" }
}

Read and cancel

Poll the event with the eventId from the send response, or withdraw it while it is still pending.

curl https://notify.belweave.ai/hooks/whk_your_token/events/evt_Cxns2IdbF4H0TJYq

curl -X POST https://notify.belweave.ai/hooks/whk_your_token/events/evt_Cxns2IdbF4H0TJYq/cancel
{
  "ok": true,
  "event": {
    "id": "evt_Cxns2IdbF4H0TJYq",
    "response": {
      "status": "approved",
      "action": "approve",
      "text": null,
      "correlationId": "deploy-184",
      "respondedAt": "2026-07-25T18:06:52.000Z",
      "expiresAt": "2026-07-25T18:19:04.000Z"
    }
  }
}
  • status is one of pending, approved, denied, yes, no, replied, expired, or canceled.
  • For text prompts, action becomes reply and text holds the answer. For the other types action holds the chosen option and text is null.
  • Reading a pending request after expiresAt settles it as expired.
  • Cancel returns 404 if the response is not pending, and events belonging to another service are never visible.

Callbacks

When a callback is configured, Notify POSTs the answer to your URL with Content-Type: application/json and a Notify-Callbacks/1 user agent. Classic callbacks also send Authorization: Bearer <callback.token>. Every callback includes Notify-Timestamp and Notify-Signature: v1=<hex> (HMAC-SHA256 of {timestamp}.{rawBody}). Redirects are not followed and the request times out after 10 seconds. You can also pass callback.target { connectorId, route, path } so Notify resolves the live clawchest.com tunnel URL at delivery time.

{
  "type": "notification.response",
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "correlationId": "deploy-184",
  "kind": "approval",
  "status": "approved",
  "action": "approve",
  "text": null,
  "respondedAt": "2026-07-25T18:06:52.000Z"
}

kind is approval, yes_no, or reply — a text request arrives as reply.

Any response outside the 2xx range is a failure. Notify makes up to five attempts: once immediately, then after 30 seconds, 2 minutes, 10 minutes, and 1 hour. After the last attempt the callback is marked failed and is not retried, so treat the callback as at-least-once and key your handler on eventId.

Callbacks fire only when someone actually answers. Prompts that expire or are canceled never call back — poll the event route if you need to observe those outcomes.

Activity API

A Live Activity is a stateful card on the Lock Screen and in the Dynamic Island. Start one, push partial updates as work progresses, then end it. Same webhook token, nested routes.

Start an activity

Activity API routes
RoutePurpose
POST /hooks/:token/live-activitiesStart an activity. Returns 201.
GET /hooks/:token/live-activities/:idRead the current state.
PATCH /hooks/:token/live-activities/:idApply a partial update.
POST /hooks/:token/live-activities/:id/endSettle and dismiss the activity.

Only title and status are required.

curl -X POST https://notify.belweave.ai/hooks/whk_your_token/live-activities \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: deploy-184-start' \
  -d '{
    "title": "Deploy #184",
    "status": "Building",
    "progress": 0,
    "symbol": "build",
    "accentColor": "#FF9F0A"
  }'
{
  "ok": true,
  "activityId": "act_9Rk2wQpLm4Tz",
  "sequence": 0,
  "status": "active",
  "accepted": 1,
  "failed": 0,
  "state": { "title": "Deploy #184", "status": "Building", "progress": 0, "symbol": "build" },
  "expiresAt": "2026-07-26T02:04:11.000Z",
  "staleAt": "2026-07-25T22:04:11.000Z",
  "endedAt": null
}

Use activityId in the update and end routes. If you pass your own key when starting, that value works in the URL too, so a script can address its activity without storing the generated ID. Idempotency-Key works on all three write routes and is scoped to the service: a replay returns idempotent: true, and reusing a key with a different payload returns 409.

Live Activities need a device running a Notify build that has registered a push-to-start token. If no device qualifies, the start still returns 201 with accepted: 0 and an explanatory message.

Update an activity

PATCH merges into the current state, so send only what changed. At least one field other than ifSequence is required. Each accepted update increments sequence.

curl -X PATCH https://notify.belweave.ai/hooks/whk_your_token/live-activities/act_9Rk2wQpLm4Tz \
  -H 'Content-Type: application/json' \
  -d '{ "status": "Testing", "progress": 0.6, "accentColor": "#64D2FF" }'
  • Pass null for detail or progress to clear the field.
  • Pass ifSequence to make the write conditional. A mismatch returns 409 Sequence conflict along with the current state so you can reconcile.
  • Updating an activity that has already ended or expired returns 409 Live Activity is already terminal.

End an activity

curl -X POST https://notify.belweave.ai/hooks/whk_your_token/live-activities/act_9Rk2wQpLm4Tz/end \
  -H 'Content-Type: application/json' \
  -d '{ "status": "Deployed", "progress": 1, "symbol": "success" }'

The body is optional. dismissAfterSeconds (0 to 14,400, default 0) controls how long the finished card lingers on the Lock Screen before iOS removes it.

status and symbol have defaults on this route, so omitting them overwrites the live values with "Complete" and success. Send them explicitly if you want the final card to read differently.

Fields and appearance

Live Activity fields
FieldTypeDescription
titlestring, requiredUp to 80 characters. Required on start, optional on updates.
statusstring, requiredShort state line, up to 60.
detailstringSecondary line, up to 240. Nullable.
progressnumber0 to 1 inclusive. Nullable.
symbolenumterminal, code, build, success, or warning. Defaults to terminal.
accentColorstringSix-digit hex, #RRGGBB. Defaults to #FFB224.
styleenumstandard, ring, hero, terminal, or steps. Defaults to standard and selects the widget layout. Updates can switch it mid-flight. App builds that predate a style fall back to the standard layout.
privacyModeenumstandard or private. Private replaces the start alert text with a generic line so the title and status stay off a locked screen.
keystring, start onlyYour own alias, up to 100 characters, usable in place of the activity ID. A key becomes reusable once its activity ends.
replaceboolean, start onlyEnd any Live Activity occupying a target device, and any of your own still holding the same key, before starting. Defaults to false. The response reports the displaced count as replaced.
deviceIdsstring[], Pro1 to 50 device IDs. Omit to target every capable device.

The five progress layouts and four interactive approval layouts, captured from the iOS simulator with the same state:

standard Live Activity on the Lock Screen and Dynamic Island
standard — Icon, title over status, trailing percent, linear progress bar. The default.
Layout illustration
ring Live Activity on the Lock Screen and Dynamic Island
ring — A determinate capacity gauge with the percent centered; no linear bar.
Layout illustration
hero — Status becomes the headline and the bar runs edge to edge along the bottom of the card.
terminal Live Activity on the Lock Screen and Dynamic Island
terminal — Monospaced prompt treatment: the status lowercased behind a prompt glyph, the detail as a comment line.
Layout illustration
steps Live Activity on the Lock Screen and Dynamic Island
steps — Progress quantized into five stage pips — phases, not percent.
Layout illustration
approval Live Activity on the Lock Screen and Dynamic Island
approval — The default interactive layout with a clear prompt and balanced approve/deny actions.
Layout illustration
shell Live Activity on the Lock Screen and Dynamic Island
shell — A terminal-native approval prompt with command-line copy and compact green actions.
Layout illustration
verdict Live Activity on the Lock Screen and Dynamic Island
verdict — A centered system-dialog treatment with a divider and high-contrast blue primary action.
Layout illustration
signal Live Activity on the Lock Screen and Dynamic Island
signal — A guarded-action card with restrained security framing and green/red decisions.
Layout illustration

Expiry and staleness

expiresInSeconds accepts 60 to 28,800 and defaults to 28,800 — eight hours. Once an activity passes its expiry it is marked expired and stops accepting updates.

staleAfterSeconds accepts 0 to 28,800 and defaults to 14,400 — four hours. Past that deadline iOS treats the content as possibly out of date, but the card stays visible and updateable. Every update rolls the deadline forward from now, clamped to the expiry. An update that omits staleAfterSeconds reuses the previous window.

One per device

A device can host one Notify Live Activity at a time. Starting another while one is still live on a target device returns 409:

{
  "ok": false,
  "error": "A Live Activity is already active on a target device",
  "code": "ACTIVE_ACTIVITY_CONFLICT",
  "activityId": "act_9Rk2wQpLm4Tz"
}

The activityId is included only when the blocking activity belongs to the same service, so you can update it instead of starting over. Branch on code rather than the message text.

To take the slot instead, pass replace: true in the start payload. Notify silently ends whatever occupies each target device — the old card is dismissed immediately, showing its last state — and then starts your activity. The success response reports how many activities were displaced as replaced; with nothing to displace the start behaves normally and replaced is 0.

replace also displaces activities started by your other services or API tokens, so a stale card from another integration cannot deadlock a device, though a foreign activityId is never disclosed. When the start carries a key, your live activity holding that key is ended everywhere — even on devices the start does not target — so the key always transfers to the new run. The implicit ends are not billed against your notification allowance. Combined with reusable keys this makes key plus replace: true a fixed-key restart you can send on every run.

Alerts and priority

A start carries an alert, so it may notify the user like a normal notification. Updates and ends carry no alert and are silent. Notify sends every Live Activity push at high APNs priority, which affects delivery speed only, not sound or haptics.

Live Activity operations count against the same per-minute and monthly limits as notifications, so a chatty progress loop consumes the same budget. Throttle to meaningful state changes.

iOS also budgets push-to-start deliveries per app. Rapid successive starts to the same device can be silently suppressed: the start still reports accepted, but the device never registers an update token, so every later update and end fails with MissingUpdateToken. Space fresh starts out by a minute or so — or keep one activity alive and update it, which is cheaper and never hits the budget.

notify CLI

notify wraps the agent API for terminals, scripts, and coding agents: one-shot notifications, questions with answers you can wait on, and Live Activities — no webhook URL required. It needs Node.js 22 or newer.

Install and sign in

The Notify CLI is coming soon. Contact notify@belweave.ai for early access.

  1. The CLI prints a short code and opens notify.belweave.ai in your browser.
  2. Sign in and approve the requested scopes; every scope is shown before you approve.
  3. Credentials are written to an OS config file with mode 0600, and the CLI polls until the approval lands.

Each login appears under your dashboard's Services list as an agent connection, with its scopes, creation date, and last use. Revoking it there signs that agent out immediately. notify auth status shows the active connection; notify auth logout revokes and removes local credentials.

Use repeatable --scope flags to narrow access, --client-name to label the connection, and --expires-in (default 90d) to bound its lifetime. For CI or self-hosted setups, NOTIFY_TOKEN and NOTIFY_API_URL environment variables override the config file.

Route agent permissions

Route permission requests from Claude Code, Codex, OpenCode V1, and OpenCode V2 to Notify with one setup command. Only an explicit phone approval grants a request, and it grants it once. Denial, timeout, malformed input, authentication failure, network failure, and no-device delivery deny.

notify permissions setup all
notify permissions doctor

The default notify login includes the required notifications:send, interactions:create, and interactions:read scopes. A narrowed login must retain all three; setup and doctor report missing scopes before hooks are installed.

Use permissions setup claude, permissions setup codex, or permissions setup opencode for one integration. After Codex setup, review and trust the hook through /hooks. OpenCode setup installs both a V1 plugin connector and the V2 background connector on macOS. notify permissions uninstall all removes only Notify-owned hooks and services.

Phone prompts contain only the agent name, permission or tool name, project directory basename, and resource count. Raw commands, patches, prompts, file contents, URLs, environment variables, transcript paths, and absolute paths are not sent to Notify.

Create a webhook service

notify services create creates a persistent webhook endpoint for another tool or workflow. Its title, image, and tap URL become defaults, so the sender only needs to POST a body. The command prints the credential-bearing webhookUrl in its JSON response.

notify services create \
  --title "Release bot" \
  --image https://example.com/bot.png \
  --url https://ci.example.com/releases
{
  "service": {
    "id": "svc_...",
    "title": "Release bot",
    "imageUrl": "https://example.com/bot.png"
  },
  "webhookUrl": "https://notify.belweave.ai/hooks/hook_..."
}

Use --stdin to provide the same fields as JSON. services list lists existing services without emitting their webhook credentials. Creation requires services:write; if your CLI login predates that scope, sign in again and approve the updated permissions.

Treat webhookUrl as a secret. Anyone who has it can send notifications through that service.

Send a notification

notify send <body> sends a one-shot push to every active iPhone on your account. Appearance is per call — the title acts as the sender name, and messages with the same title thread together like a service.

notify send "Build 48 passed" \
  --title "CI" \
  --image https://github.com/github.png \
  --url https://ci.example.com/builds/48
send flags
FlagTypeDescription
--titlestringSender name. Defaults to Notify.
--imageurlPublic HTTPS avatar, same rules as the webhook imageUrl.
--urlurlWeb URL, app deep link, or Shortcuts URL opened when tapped.
--deviceid, repeatableTarget specific iPhones. Requires Notify Pro.
--projectstringFile the notification into a named project in the Notify app inbox.
--summarystringShort push/preview text for a long body. Bodies can hold up to 8,000 characters.
--markdownbooleanRecord the body as Markdown (same as --body-format markdown); V1 renders plain text.
--appapp idOpen this web app in Notify when tapped; --url must stay on its origin.
--idempotency-keystringSafe retries: replays return the original result without a second push.
--stdinbooleanMerge a JSON object from stdin under the explicit flags.

Sends from one connection share the webhook per-minute budgets — the requester counts like a service, the account window spans everything — and the same monthly notification allowance.

Ask a question

notify send ask <prompt> sends a push that elicits an answer. Pass exactly one response type: --approval (Approve/Deny), --yes-no (Yes/No), or --text (a short typed reply). The appearance flags from send all apply.

notify send ask "Deploy 8e7fc2a to production?" \
  --approval --title "Deploybot" --wait --timeout 15m

Add --live-activity to an approval or yes/no request to put its buttons on the Lock Screen and expanded Dynamic Island. --style selects approval, shell, verdict, or signal. --primary-label and --secondary-label customize visible verbs such as Send/Deny or Push/Cancel while the returned action remains canonical. Interactive Live Activity prompts are limited to 240 characters, action labels are 1 to 24 characters, requests require iOS 17+, expire within eight hours, and don't support text replies, images, or URLs.

notify send ask "Send the prepared release email?" \
  --approval --live-activity --style signal \
  --primary-label Send --secondary-label Deny \
  --wait --timeout 15m

--wait blocks until the answer arrives or the timeout passes. --poll waits at most 20 seconds to catch an instant answer, for the case where someone is already looking at their phone. A timed-out poll or wait does not end the prompt — it stays answerable until it expires (default 15 minutes, --expires-in to change), and notify interaction wait <id> resumes waiting any time.

{
  "interaction": {
    "id": "int_7MFuml-SqoUmpPLo",
    "kind": "reply",
    "status": "replied",
    "response": "ship it",
    "respondedAt": "2026-07-26T11:54:17.927Z"
  },
  "accepted": 1,
  "timedOut": false
}

Drive a Live Activity

The activity commands drive the Activity API end to end. Address an activity by the returned ID or by your own --key, and pick a layout with --style.

notify activity start --key deploy --replace --style ring \
  --title "Deploy #184" --status "Building" --progress 0.1

notify activity update deploy --status "Testing" --progress 0.6 --if-sequence 0

notify activity end deploy --status "Shipped" --progress 1 --dismiss-after 45s
  • --replace takes the device slot and the key, ending whatever blocks them, so a fixed-key start works on every run.
  • --if-sequence rejects stale writes: the update only applies if the activity is still at that sequence.
  • --style selects standard, ring, hero, terminal, or steps, and can change mid-flight on update.
  • activity get <id|key> reads current state; activity list shows recent activities.

Scripting and exit codes

Every successful command prints exactly one JSON object to stdout (apps commands print readable lines unless you pass --json); diagnostics go to stderr. Exit codes make answers branchable without parsing:

  • 0 — success, approved, yes, or replied
  • 4 — timed out, canceled, or expired
  • 5 — denied or no
  • 7 — no device accepted the push
  • 1 API error · 2 usage error · 3 authentication or scope error · 6 network error
if notify send ask "Deploy to production?" --approval --wait --timeout 10m; then
  ./deploy.sh && notify send "Deployed" --title "Deploybot"
else
  echo "Not approved" >&2
fi

Web apps

Open any HTTPS site you control full-screen in the Notify iPhone app. Notify hands the page a short-lived signed Notify pass, so your site can identify the viewer without building its own login.

Register an app

Register an app with notify apps create. Registering the same URL again updates its name, icon, or project. Apps appear in the Notify iPhone app, which asks you to approve sign-in the first time one opens; you can revoke that approval or choose whether your name and email are shared at any time.

notify apps create --name "Ops dashboard"   --url https://ops.example.com --icon https://ops.example.com/icon.png

notify apps list
notify apps remove app_...

Launch URLs must use HTTPS; plain HTTP is accepted only for localhost development servers. Accounts hold up to 100 apps. Agent tokens need the apps:read and apps:write scopes; sign in again if your login predates them.

Verify the Notify pass

  1. Inside the Notify app, your page calls await window.notify.getToken(), which resolves to a pass string. window.notify exists only in Notify; window.notify.close() returns to the app.
  2. The page sends the pass to your server, which verifies it as an ES256 JWT against the Notify JWKS — for example with jwtVerify from jose.
  3. Require issuer https://notify.belweave.ai, audience equal to your app's origin, typ notify-pass+jwt, and algorithm ES256, then start your own session.
Notify pass claims
FieldTypeDescription
substringStable user ID for your origin (hk_…). It differs for every other origin, so apps cannot correlate users.
audstringYour app's origin, e.g. https://app.example.com.
iat / expnumberPasses expire two minutes after issue.
jtistringUnique per pass; reject reused values to block replay.
app_idstringThe Notify app ID the pass was issued for.
name / emailstringPresent only when the owner shares them. Name is shared by default; email is not.
team_id / team_rolestringPresent for team apps: the viewer's team and their current role (owner, admin, or member).

Never trust a pass you have not verified, and never treat it as a long-lived credential: verify once, then rely on your own session.

Open from a notification

Pass appId on a webhook (or --app to notify send) to open the app when the notification is tapped. Add url to deep-link within it; it must share the app's origin.

{
  "body": "Nightly report is ready",
  "appId": "app_...",
  "url": "https://ops.example.com/reports/latest"
}

Connect internal services

Notify Connector wraps OpenTunnel so a process behind a customer firewall gets a public HTTPS hostname on clawchest.com. Notify stays the human surface — approvals, pages, and web apps — while the tunnel is only reachability. Every route still authenticates with a Notify pass or a signed callback.

How the tunnel works

The relay API is https://api.clawchest.com. Customer tunnels are https://<id>.clawchest.com and https://<route>.<id>.clawchest.com. Override the apex with NOTIFY_RELAY_DOMAIN. Creating a tunnel requires a Notify agent token (ntf_…); the Worker introspects it at /api/relay/introspect. The tunnel itself never authenticates visitors.

  • @belweave/notify-connector points @opentunnel/client at the relay, declares routes, registers each one as a Notify web app, and heartbeats.
  • A connector that stays silent longer than CONNECTOR_OFFLINE_MINUTES (default 5) is marked offline. If it has an oncallGroupId, Notify pages that schedule.
  • Approvals can target { connectorId, route, path } so the live tunnel URL is resolved at delivery time.

Start a connector

From a laptop or a machine on the private network:

export NOTIFY_TOKEN=ntf_...
notify connect 3000 --name grafana
# https://grafana.<id>.clawchest.com

The connector registers https://grafana.<id>.clawchest.com as a Notify web app. Open it in the Notify iPhone app; the pass aud is that origin, so a pass for Grafana cannot be replayed against a sibling route. notify connect prints callbackSecret once and writes it to ~/.config/notify/connectors/<id>.secret (mode 0600). Re-login with --scope connectors:write before connecting; default login does not include it. Rotate with notify connectors rotate-secret <id>.

Approval callback to a laptop

Ask for approval and POST the answer to a script on the same machine. Notify resolves the tunnel URL when it delivers, then signs the body.

# On the laptop: a tiny receiver on port 8780, then
# notify connect 8780 --name cb

# From CI or an agent:
notify notify ask "Ship production?" --approval --stdin <<'JSON'
{
  "callback": {
    "target": {
      "connectorId": "conn_...",
      "route": "cb",
      "path": "/approve"
    }
  }
}
JSON

Every callback includes Notify-Timestamp and Notify-Signature: v1=<hex>. The hex is HMAC-SHA256 of {timestamp}.{rawBody} using the connector callbackSecret (returned once on create). For a classic url + token callback the HMAC secret is the callback token, and Authorization: Bearer is still sent. Reject timestamps older than 5 minutes.

{
  "type": "notification.response",
  "eventId": "evt_...",
  "kind": "approval",
  "status": "approved",
  "action": "approve",
  "text": null,
  "respondedAt": "2026-10-09T12:00:00.000Z"
}

Verify the HMAC before acting. @belweave/notify-connector exports verifyCallbackSignature. Treat the body as untrusted data, not shell instructions. Deliveries are at-least-once: dedupe on eventId before you act.

Internal dashboard with pass SSO

An internal dashboard on the laptop is opened in Notify with pass SSO. The connector registers the tunnel URL as the web app; your server verifies notify-pass+jwt against the Notify JWKS with aud equal to the route origin.

@belweave/notify-connector exports honoNotifyPass, expressNotifyPass, and fastifyNotifyPass. Point issuer at https://notify.belweave.ai and audience at the route origin (https://grafana.<id>.clawchest.com). The middleware checks typ notify-pass+jwt against /.well-known/jwks.json.

Teams

A team is the organization: it shares web apps, device groups, on-call rotations, an audit log, and retention settings between Notify accounts. Everyone keeps their own phone, inbox, and sign-in decisions; the team only decides what is shared. SSO and SCIM are the next phase.

Teams and roles

Create a team in the dashboard or with notify teams create. You become its owner. Each member has one role:

Team roles
FieldTypeDescription
ownerone per teamEverything an admin can do, plus deleting the team, managing billing, transferring ownership, verifying the email domain, auto-join, and retention. The owner must transfer ownership before leaving.
adminroleRenames the team, invites and removes members, changes roles, removes any team app, creates and edits on-call and device groups, and reads the audit log.
memberroleUses and adds team apps, raises and acknowledges pages, and schedules overrides for themselves.

Transferring ownership (setting someone's role to owner) makes the previous owner an admin. Removing a member takes effect immediately: they stop receiving Notify passes for the team's apps and leave every rotation.

Seats and pricing

Every member uses a seat. The first seat is free, so a team of one costs nothing. Each additional member needs the team plan at $5 per seat per month, billed to the team (not to anyone's personal plan) and prorated as people join and leave.

Without the team plan, creating or accepting an invite for a second member returns 402 with code seat_limit. Owners and admins start checkout or open the billing portal from the team page in the dashboard.

Pages count against the notification allowance of whoever raised them (the agent's or service's owner), like any other notification. Team notices such as invites and shared apps are free.

Invite people

Owners and admins create invite links. Each link joins one person, expires after seven days, and can be revoked. Add an email to also push the invite to an existing Notify user with that address. Add domain (the org's verified domain) to restrict the link to that email domain.

notify teams invite team_... --email teammate@example.com --role member
notify teams invite team_... --domain acme.com --role member
# { "invite": { ... }, "code": "…", "url": "https://notify.belweave.ai/join/…" }

Opening the link shows the team, who invited you, and the role. Joining requires signing in: agents can create invites but never accept them.

Organization settings

An organization is a team with a few extra settings, not a parallel model. The owner requests a domain (their own email must already be verified on that domain). Notify issues a random token and stores it as pending — pending domains are not unique. Publish a DNS TXT record at _notify-verification.<domain> with value notify-verify=<token>, then call verify. Only a successful TXT lookup sets verifiedDomain, which is unique. Public mailbox domains (gmail.com, outlook.com, hotmail.com, yahoo.com, icloud.com, me.com, privaterelay.appleid.com, proton.me, and similar) cannot be claimed. Auto-join is off by default, owner-only, and can be enabled only after the domain is verified; it joins users whose emailVerified is exactly true when a seat is free. SSO and SCIM should reuse verifiedDomain and call the same auto-join helper.

Retention (7, 30, 90, or 365 days, or null for forever) applies to org-targeted sends only (group-attributed notifications, prompts, and pages). Personal sends are never purged. An hourly job deletes expired rows asynchronously in small indexed batches, yielding between batches, and skips pending prompts, events that still have pending prompts, and active Live Activities. If a tick hits its time budget, the next batch is scheduled within a few seconds until the queue is drained.

notify teams domain team_... acme.com
# Add TXT _notify-verification.acme.com = notify-verify=<token>
notify teams verify-domain team_...
notify teams update team_... --auto-join --retention 30
notify teams update team_... --no-domain --no-retention

Device groups

Device groups are named sets of org members and/or specific devices (dgrp_…), distinct from on-call rotation groups. Admins manage them in the dashboard. Org admins and owners, or current members of that group, can target it from notify, ask, Live Activities, and pages; other teammates receive 403. group cannot be combined with deviceIds or oncall. Cross-org IDs return 404. A group ask can be answered by any current group member; the first valid response wins.

notify groups create --team team_... --name SRE --members user_a,user_b
notify send "Disk full" --group dgrp_...
notify send ask "Ship it?" --approval --group dgrp_...
notify activity start --title Deploy --status Running --group dgrp_...
notify page ocg_... "Pager" --group dgrp_...

Audit log

The audit log is append-only. Owners and admins can read it in the dashboard (filter plus CSV export) or via GET /api/agent/teams/:id/audit. Members receive 403. from is inclusive. CSV export paginates up to 50,000 rows and sets X-Audit-Truncated: true when more exist. Events include invite create/revoke, member add/remove/leave/role change, team delete, device-group changes, app share, org-targeted notification sends (including group Live Activities and group pages), approval responses (the responder is the actor), and settings changes. Token create/revoke is not written to org audit logs. Rows store actor, target, IP, user agent, and timestamp — never notification bodies or replies.

Team apps

Any member can add a web app to a team, or move one of their own apps into it with notify apps share app_... --team team_... (--personal moves it back). The other members are notified and see the app next to their own.

  • Every member approves sign-in and chooses whether their name and email are shared for themselves; nobody approves on someone else's behalf.
  • Passes for team apps carry the usual pairwise sub plus team_id and team_role, checked at issue time, so your site can authorize by team.
  • The person who added an app, and team admins, can rename or remove it. Deleting a team returns its apps to the people who added them.

On-call

On-call groups page whoever is on call right now, then escalate until someone takes it. Pages arrive as time-sensitive notifications with Acknowledge and Escalate actions on the Lock Screen.

Groups and rotations

Team owners and admins create on-call groups. A rotation is an ordered list of team members who hand off daily or weekly at a local time in an IANA time zone. Handoffs follow the local clock across daylight-saving changes, so a 09:00 handoff stays at 09:00.

notify oncall create --team team_... --name Primary \
  --members user_a,user_b,user_c --period weekly \
  --handoff 09:00 --timezone America/New_York

Without startsAt, the first member is on call from the latest handoff. Overrides put someone on call for a window (covering a shift, a holiday) and replace the rotation while they last; any member can schedule one for themselves. Each group lists its current shift and the next few.

Paging and escalation

A page goes first to the person on call. If nobody is on call, the whole group is paged. Escalation steps then run until someone acknowledges:

Escalation steps
FieldTypeDescription
afterMinutesintegerMinutes after the previous step (or the page) without an acknowledgement.
targetnext | groupnext pages the next person in the rotation; group pages everyone in it who has not been paged yet.

The default is the next person after 5 minutes, then the whole group 10 minutes later. Pages with the same dedupKey merge into the open page (its repeatCount grows) instead of paging again.

notify page ocg_... "API error rate above 20%" \
  --body "5xx since 14:02" --dedup-key api-5xx

Page from a webhook

Add oncall to a webhook payload to page a group instead of notifying your own devices. The service owner must belong to the group's team, and oncall cannot be combined with deviceIds or response. The Idempotency-Key header becomes the page's dedup key, so retries merge into the open page.

{
  "title": "Checkout API",
  "body": "Error rate above 20% for 5 minutes",
  "oncall": "ocg_..."
}

The response carries pageId and delivered. Page bodies are limited to 2,000 characters. Agents page with notify page or notify send --oncall.

Acknowledge and resolve

Anyone on the team can acknowledge a page from the Lock Screen, the app, or the website. The first acknowledgement wins: escalation stops, the page clears from everyone else's phone, and later acknowledgements are refused. Escalate pages the next step right away.

Acknowledging and escalating are human-only. An acknowledgement tells the team a person is on it, so agents can raise, read, and resolve pages but have no route to acknowledge or escalate them.

Resolve a page when the incident is over, from the website or with notify pages resolve page_... --note "Rolled back". Resolving an unacknowledged page also stops its escalation.

Agent API

Everything you can do in the dashboard or the phone inbox is also available to a scoped agent token, except the few decisions only you should make. notify wraps every route.

Tokens and scopes

Send Authorization: Bearer ntf_… with a token from notify auth login or the dashboard's Agent connections. Each route requires the scopes listed below; a missing scope returns 403 with required naming them. Every resource is scoped to the token's account, and other accounts' IDs return 404.

  • Default notify logins request every scope except events:read and tokens:manage; add those with --scope.
  • devices:write, inbox:read, inbox:write, billing:read, and tokens:manage were added with this API, teams:read, teams:write, oncall:read, and oncall:write with teams, and connectors:read / connectors:write with the clawchest.com relay; older logins must sign in again to use them.
  • Agent reads of services never include webhook URLs. Only create and rotate return a URL, once.

Routes

Agent API routes
RoutePurpose
GET /api/agent/auth/statusDescribe the calling token. Any scope.
POST /api/agent/auth/revokeRevoke the calling token. Any scope.
POST /api/agent/notificationsSend a push. notifications:send.
POST /api/agent/notifications/:id/withdrawRemove a sent agent push from Notification Center and mark it read. notifications:send.
GET /api/agent/interactionsPending prompts from every source on the account. interactions:read.
POST /api/agent/interactionsAsk a question. interactions:create and notifications:send.
GET /api/agent/interactions/:idRead a prompt this token created; /wait long-polls it. interactions:read.
POST /api/agent/interactions/:id/cancelCancel a prompt this token created. interactions:create.
GET /api/agent/inbox/projectsInbox projects with unread counts. inbox:read.
GET /api/agent/inbox/notificationsInbox page: limit, project, unread, cursor. inbox:read.
GET /api/agent/inbox/notifications/:idOne notification with its full body. inbox:read.
POST /api/agent/inbox/notifications/:id/readMark read; /unread reverses it. inbox:write.
POST /api/agent/inbox/notifications/read-allMark read up to a list response's readThroughToken. inbox:write.
GET /api/agent/activity-feedAccount activity history: filter, page. events:read.
GET /api/agent/eventsRecent webhook deliveries. events:read.
GET /api/agent/servicesList services, or /:id for one, without webhook URLs. services:read.
POST /api/agent/servicesCreate a service; the webhook URL is returned once. services:write.
PATCH /api/agent/services/:idChange title, avatar, or tap URL; DELETE removes it. services:write.
POST /api/agent/services/:id/rotateReplace the webhook token; the new URL is returned once. services:write.
GET /api/agent/devicesRegistered devices. devices:read.
DELETE /api/agent/devices/:idRemove a device until Notify next opens on it. devices:write.
GET /api/agent/appsList apps, or /:id for one. apps:read.
POST /api/agent/appsRegister an app; an existing URL is updated. apps:write.
PATCH /api/agent/apps/:idChange name, URL, icon, or project; DELETE removes it. apps:write.
POST /api/agent/apps/:id/revokeSign the app out until the owner approves it again. apps:write.
POST /api/agent/apps/:id/shareMove an app you added into a team, or back. apps:write.
GET /api/agent/connectorsList tunnel connectors. connectors:read.
POST /api/agent/connectorsRegister a connector; callbackSecret is returned once. connectors:write.
POST /api/agent/connectors/:id/heartbeatMark a connector online. PATCH / DELETE update or remove it. connectors:write.
GET /api/agent/teamsYour teams, or /:id for one with its members. teams:read; POST, PATCH, and DELETE need teams:write.
POST /api/agent/teams/:id/invitesCreate a join link; GET lists them. teams:write / teams:read.
PATCH /api/agent/teams/:id/members/:userIdChange a role; DELETE removes the member. teams:write.
GET /api/agent/teams/:id/groupsDevice groups; POST creates one. GET/PATCH/DELETE /:groupId. teams:read / teams:write.
GET /api/agent/teams/:id/devicesDevices belonging to team members (admin). teams:read.
GET /api/agent/teams/:id/auditAudit log for admins; format=csv exports. Query action, actor, from, to, cursor, limit. teams:read.
GET /api/agent/teams/:id/oncallThe team's on-call groups; POST creates one. oncall:read / oncall:write.
PATCH /api/agent/oncall/:groupIdEdit a group, its /overrides, or raise /pages. oncall:write.
GET /api/agent/teams/:id/pagesThe team's pages: status, cursor, limit. oncall:read.
POST /api/agent/pages/:id/resolveResolve a page; GET /api/agent/pages/:id reads one. oncall:write.
GET /api/agent/activitiesLive Activities this token started; full CRUD under /:identifier. activities:read / activities:write.
GET /api/agent/billingPlan, limits, and remaining usage. billing:read.
GET /api/agent/tokensThe account's tokens, never their secrets. tokens:manage.
DELETE /api/agent/tokens/:idRevoke another token of the account. tokens:manage.
notify inbox list --unread --limit 20
notify inbox read-all --project unfiled
notify interaction list
notify send withdraw anot_...
notify services rotate svc_...
notify apps update app_... --name "Ops board"
notify billing
notify tokens list   # needs --scope tokens:manage at login

Human-only actions

Some actions have no agent route on purpose. An agent may reduce access, but only a person on their phone or signed in to the dashboard can grant it.

  • Answering prompts: an approval means a human approved, so agents can list and cancel prompts but never respond to them.
  • Creating API tokens: a token that could mint tokens could grant itself any scope and outlive its own revocation. Agents can list and revoke tokens with tokens:manage.
  • App sign-in: approving sign-in, issuing Notify passes, and choosing whether your name and email are shared stay on the phone. Moving an app's URL to a new origin clears its approval.
  • Registering devices, which needs the iPhone's push token.
  • Starting checkout or opening the billing portal, for you or a team.
  • Accepting a team invite: joining a team is your decision.
  • Acknowledging or escalating a page: an acknowledgement tells the team a person is on it.

OpenAPI document

A public OpenAPI 3.1 document describes every agent route, its scopes (x-notify-scopes), request schema, and response shape. Request schemas are generated from the same validators the server uses.

https://notify.belweave.ai/api/agent/openapi.json

MCP server

Notify is also a remote MCP server, so Claude, OpenCode, Cursor, and other MCP clients can use every agent API operation as a tool. It signs in with OAuth: there is no token to paste.

Connect a client

Add the server URL to your MCP client. It uses the Streamable HTTP transport.

https://notify.belweave.ai/mcp

OpenCode reads remote servers from opencode.json:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "notify": { "type": "remote", "url": "https://notify.belweave.ai/mcp" }
  }
}

Claude Code adds it from the terminal, and Cursor reads ~/.cursor/mcp.json:

claude mcp add --transport http notify https://notify.belweave.ai/mcp
{
  "mcpServers": {
    "notify": { "url": "https://notify.belweave.ai/mcp" }
  }
}

Sign-in and permissions

  1. Your client calls the server, gets 401 with a resource_metadata pointer, and registers itself with Notify (dynamic client registration).
  2. Your browser opens Notify's consent page. Sign in with Apple or Google if needed.
  3. Review what the client asks for, untick anything you do not want, and approve. Notify remembers your answer for that client and set of permissions.
  4. The client receives a one-hour access token for /mcp, plus a refresh token if you left Stay connected ticked.

OAuth scopes are the agent API scopes one to one, plus offline_access for Stay connected. A client that asks for no scope requests everything except tokens:manage. PKCE (S256) is required, and tokens are only valid for the /mcp resource.

Connected clients are listed on the dashboard; Disconnect revokes their access and refresh tokens at once. With tokens:manage, agents see them as kind: "oauth" entries from GET /api/agent/tokens and can revoke them too.

Discovery documents
RoutePurpose
GET /.well-known/oauth-protected-resource/mcpProtected resource metadata (RFC 9728) for the MCP server.
GET /.well-known/oauth-authorization-serverAuthorization server metadata (RFC 8414): authorize, token, and registration endpoints.

Tools

There is one tool per agent API route, and each runs through that route's handler, so validation, scopes, and limits are identical. A tool the connection was not granted a scope for returns an error naming the missing scope.

  • notify sends a push; notification_withdraw removes it.
  • ask sends an approval, yes/no, or reply prompt and waits up to ten minutes for your answer, sending progress updates while it waits. interactions_create, interactions_wait, interactions_get, interactions_list, and interactions_cancel split that up.
  • activities_start, activities_update, activities_end, activities_get, and activities_list drive Live Activities.
  • services_*, devices_*, events_list, activity_feed, inbox_*, apps_*, billing_get, and tokens_* manage the account.
  • teams_*, groups_*, oncall_*, pages_*, and connectors_* manage teams, device groups, rotations, pages, and tunnel connectors.
  • auth_status shows the connection's scopes; auth_revoke disconnects the client.

The human-only actions above are never tools: answering prompts, acknowledging or escalating pages, accepting invites, approving app sign-in, changing sharing or issuing passes, creating tokens, and billing checkout. Webhook URLs and join links in tool results are shown once and flagged as secrets.