Actions and webhooks
Agent Notifier notifications can include action buttons and text replies. When the user taps a button or sends a reply, the app records the response with the API. Local agents can then poll or stream a durable inbox and acknowledge each response after handling it; projects can also receive signed outbound webhook callbacks.
For the common "pause the agent until a human decides" workflow, prefer POST /v1/approvals. It wraps message send, decision waiting, auto-ack, timeout defaults, and per-agent response shaping into one API call.
Button labels
Send action buttons as part of POST /v1/messages:
{
"type": "alert",
"title": "Deploy approval needed",
"message": "Promote build 1.2.3 to production?",
"buttons": ["Approve", "Retry", "Rollback", "Cancel"]
}
Free-tier notifications may expose fewer lock-screen buttons; Pro is planned for up to four.
First-class approvals
Agents can send a blocking approval request with:
POST /v1/approvals
Minimal request:
{
"project": "agent-notifier",
"session_id": "00000000-0000-0000-0000-000000000000",
"title": "Run tool: Bash",
"message": "rm -rf node_modules",
"event": "PreToolUse",
"tool_name": "Bash",
"command": "rm -rf node_modules",
"decisions": [
{"key": "allow", "label": "Allow"},
{"key": "deny", "label": "Deny", "is_default_on_timeout": true}
],
"timeout_seconds": 300,
"response_format": "claude_code.pretooluse",
"wait": "block"
}
Wait modes:
wait | Behavior |
|---|---|
none | Returns 202 with approval_id and notification_id; caller can poll/stream. |
block | Holds the request until a decision or timeout, then auto-acks and returns response_payload. |
sse | Streams sent then decided events over text/event-stream. |
Initial response_format renderers are raw, claude_code.pretooluse, claude_code.userpromptsubmit, copilot_cli.pretooluse, copilot_cli.permissionrequest, codex.permission, and cursor.beforeshellexecution.
On timeout, the first decision marked is_default_on_timeout is returned with is_timeout_default: true; if none is marked, the first decision is used.
Sessions
API-key clients can create explicit sessions before sending messages or approvals:
POST /v1/sessions
{
"project": "agent-notifier",
"label": "Tuesday morning run",
"agent": "claude-code",
"agent_version": "2.1.119",
"metadata": {"repo": "agent-notifier"}
}
Useful session endpoints:
| Endpoint | Purpose |
|---|---|
GET /v1/sessions | List sessions for the API-key project. |
GET /v1/sessions/{id} | Fetch one session. |
GET /v1/sessions/{id}/tree | Fetch parent/child notification tree for debugging subagents. |
GET /v1/sessions/{id}/responses | Session-scoped action response inbox. |
POST /v1/sessions/{id}/end | Mark a session complete or error. |
Implicit session creation on first message still works for backward compatibility, but explicit creation is preferred for new agents.
Recording an action
The iOS app records taps through:
POST /v1/actions
Request body:
{
"notification_id": "00000000-0000-0000-0000-000000000000",
"action": "button_pressed",
"button_index": 0,
"session_id": "00000000-0000-0000-0000-000000000000"
}
This endpoint uses Bearer JWT auth because it is called by the app for an authenticated user.
Polling action responses
Local agents and hooks poll durable user responses with an API key:
GET /v1/action-responses
Use the X-API-Key header. The endpoint returns a project-scoped inbox of user button taps and text replies.
Project selection depends on the API key scope:
| Key scope | Selector behavior |
|---|---|
| Account-scoped API key | Must pass either project_id=<uuid> or project=<name>. project_id takes precedence. The selected project must already belong to the API-key owner. |
| Legacy project-scoped API key | The backend infers the project from the key and ignores project_id / project. |
Useful query parameters:
| Query | Default | Notes |
|---|---|---|
project_id | — | Project UUID selector for account-scoped keys. |
project | — | Project name selector for account-scoped keys when the UUID is not available. |
session_id | — | Optional session UUID filter. |
notification_id | — | Find responses for one notification. |
parent_id | — | Find responses under a parent/subagent root. |
kind | — | button or reply. |
client_event_id | — | Match action or notification correlation IDs. |
approval_id | — | Match a first-class approval created by POST /v1/approvals. |
decision_key | — | Match structured approval decision key, action text, or button label. |
after | — | Optional RFC3339 lower bound on recorded_at. |
decided_after | — | Optional RFC3339 lower bound on the user's tap/reply timestamp. |
cursor | — | Cursor from a previous next_cursor for deterministic keyset pagination. |
limit | 50 | Page size from 1 to 100. |
unacked_only | true | Pending-only by default. Set false to include processed, ignored, and failed responses. |
Example response:
{
"items": [
{
"id": "00000000-0000-0000-0000-000000000001",
"notification_id": "00000000-0000-0000-0000-000000000000",
"project_id": "00000000-0000-0000-0000-000000000002",
"session_id": "00000000-0000-0000-0000-000000000003",
"kind": "button",
"action": "button_pressed",
"button_index": 0,
"button_label": "Approve",
"ack_status": "pending",
"recorded_at": "2026-04-28T12:00:00Z",
"notification_title": "Deploy approval needed",
"notification_message": "Promote build 1.2.3 to production?"
}
],
"next_cursor": "00000000-0000-0000-0000-000000000004"
}
If next_cursor is present, poll the next page with cursor=<next_cursor> until the response omits next_cursor. This prevents large pending batches or same-timestamp replies from being dropped.
Streaming action responses
For lower-latency local agents, use Server-Sent Events instead of polling:
GET /v1/action-responses/stream?session_id=00000000-0000-0000-0000-000000000000
Accept: text/event-stream
The stream emits action_response events with the same item shape as the paginated endpoint, plus : heartbeat comments roughly every 30 seconds. Resume with Last-Event-ID or the cursor query parameter.
Acknowledging action responses
After the local handler accepts a response, acknowledge it:
POST /v1/action-responses/{id}/ack
For account-scoped API keys, pass the same project_id or project selector used for polling. This keeps guessed cross-project IDs returning 404. Legacy project-scoped keys infer their project from the key.
Request body is optional. When omitted, the status defaults to processed:
{
"status": "processed"
}
Allowed acknowledgement statuses are:
| Status | Use when |
|---|---|
processed | The handler completed successfully. |
ignored | The handler intentionally took no action. |
failed | The handler tried and failed; include a short redacted error string if useful. |
Acknowledgement is idempotent after a response leaves pending: repeat acks return the existing terminal status instead of overwriting it. Ack after handler success, not before; leaving a response pending is the retry signal for the next poll.
Simulated decisions for tests
E2E harnesses can inject a button decision without a real phone tap:
POST /v1/test/simulate-decision
This endpoint requires an API key created with test_mode: true.
{
"notification_id": "00000000-0000-0000-0000-000000000000",
"decision_key": "allow",
"device": "test-runner"
}
Normal production API keys receive 403.
Idempotency and redaction
POST /v1/actionsacceptsclient_event_id; duplicate client event IDs return the original response instead of recording another tap/reply.- Treat
response_textas untrusted user input. Do not evaluate it as shell, SQL, JSONPath, or code. - Log response IDs, notification IDs, project IDs,
ack_status, and redacted handler status. Avoid logging API keys, webhook secrets, raw APNs tokens, full reply text, or secret-shaped metadata. - If a handler writes proof logs, prefer boolean summaries such as
has_response_textover storing the text itself.
Outbound webhook payload
When a project has a webhook_url, the backend sends a signed payload similar to:
{
"notification_id": "00000000-0000-0000-0000-000000000000",
"action": "button_pressed",
"button_index": 0,
"session_id": "00000000-0000-0000-0000-000000000000",
"timestamp": 1777248000,
"nonce": "b7c1f9d0"
}
Webhook URLs must be public http or https URLs with a valid hostname. The backend rejects empty/invalid URLs, embedded credentials, invalid ports, localhost and Docker-internal hosts, private/link-local/loopback/multicast/unspecified IPs, local-only suffixes such as .localhost, .local, .internal, .lan, .home, and single-label or all-numeric hostnames. Validation runs when projects are created or updated and again before delivery.
Expected security headers:
| Header | Purpose |
|---|---|
X-Signature-256 | HMAC-SHA256 signature over the payload. |
X-Timestamp | Unix timestamp used for replay-window checks. |
X-Nonce | One-time nonce used to prevent replay. |
The current decisions log requires a 5-minute replay window for webhook security. Agents receiving webhooks should reject stale timestamps, reused nonces, and invalid signatures.
Agent behavior
- Treat action callbacks as asynchronous; the original send response only confirms message acceptance.
- Poll
GET /v1/action-responsesuntilnext_cursoris exhausted, then ack each handled response. - Ack only after local handler success; use
ignoredorfaileddeliberately and keep errors short/redacted. - Make callbacks idempotent by
id,client_event_id, ornotification_id+button_index. - Log action IDs and statuses, not webhook secrets, API keys, or raw reply text.