# Plush API Reference

Base URL: https://api.easypush.app
Auth: Bearer token from Plush iOS app. Header: `Authorization: Bearer $PLUSH_API_TOKEN`.
Hosted API requires active subscription. Self-hosted Worker can use `BILLING_MODE=custom`.
Playground: https://easypush.app/play
Codex hook/MCP guide: https://easypush.app/codex.md
Claude Code hook guide: https://easypush.app/claude.md
Widget JS runtime guide: https://easypush.app/runtime.md
JS SDK/CLI package: @easypush/plush

## Quick Send
```sh
curl -X POST https://api.easypush.app/v1/send \
  -H "Authorization: Bearer $PLUSH_API_TOKEN" \
  -d 'Deploy done'
```

Equivalent JSON:
```json
{"title":"Deploy done","body":"main is live","thread_id":"deploy-main"}
```

## Endpoints
- `POST /v1/auth/apple`: app-only. Body `{"identityToken":"..."}`. Returns user + apiToken.
- `POST /v1/auth/standalone`: app-only. Body `{"installationId":"..."}`. Returns single-device user + apiToken.
- `GET /v1/me`: current user and billing.
- `POST /v1/tokens/rotate`: rotate current API token.
- `GET /v1/devices`: list linked devices.
- `POST /v1/devices`: register/update current device.
- `PATCH /v1/devices/:id`: rename or enable/disable device.
- `DELETE /v1/devices/:id`: remove device.
- `POST /v1/pushes`, `POST /v1/send`: send push, or send a server-rendered widget payload with `widget`.
- `GET /v1/send?message=...&title=...`: query send with bearer auth.
- `GET /v1/:token?message=...&title=...`: webhook-style send with token in path.
- `GET /v1/widgets`: list cloud-persisted widgets.
- `POST /v1/widgets`: create/update/delete a cloud widget and request a WidgetKit refresh.
- `DELETE /v1/widgets/:id`: delete a cloud widget.
- `POST /v1/widgets/tokens`: app-only. Register WidgetKit push tokens.
- `GET /v1/questions`: list recent hosted question prompts and responses.
- `GET /v1/questions/:id`: read one hosted question prompt and its response.
- `GET /v1/integrations`: list active agent integrations and their short-lived capabilities.
- `POST /v1/integrations/heartbeat`: agent hook/MCP heartbeat. Body `{"kind":"codex|claude-code","instanceId":"...","capabilities":["notify","questions","reply"],"ttlSeconds":7200}`.
- `GET /v1/live-activities`: list registered ActivityKit push tokens, redacted.
- `POST /v1/live-activities/tokens`: app-only. Register push-to-start or update token.
- `POST /v1/live-activities`: start, update, or end a Live Activity.

## Register Device
```json
{
  "installationId": "device-local-uuid",
  "name": "iphone",
  "platform": "iOS",
  "pushToken": "apns-token",
  "apnsEnvironment": "production",
  "publicKey": "base64-public-key",
  "publicKeyAlgorithm": "x25519-hkdf-sha256-aes-gcm",
  "appVersion": "1.0",
  "osVersion": "iOS 26"
}
```

Device names must be camelCase, kebab-case, or snake_case.

## Send Push Fields
Top-level compact fields: `to`, `target`, `title`, `subtitle`, `body`, `message`, `sound`, `threadId`, `thread_id`, `category`, `interruptionLevel`, `interruption_level`, `relevanceScore`, `relevance_score`, `expirationDate`, `expiration_date`, `openUrl`, `open_url`, `app`, `communication`, `question`, `callback`, `encrypted`.
`app` is local Plush history metadata: `"Codex"` or `{"name":"Codex","iconUrl":"https://..."}`. For supported iOS communication-notification surfaces, send `communication.senderName` and `communication.avatarUrl` when you also want the system notification treatment to show a sender name/avatar. Plush sends `badge:0` so app-icon notification badges do not accumulate.

Targets:
- omit `to` or use `"all"`: all enabled devices.
- `"to":"iphone"`: one device by name.
- `"to":["iphone","ipad"]`: multiple names.
- `{"kind":"deviceIds","ids":["..."]}`: explicit device IDs.

Threads: set `thread_id` so Plush groups local history as a stack.

Critical alert request:
```json
{"title":"Door left open","body":"Garage open 10 minutes","interruption_level":"critical","volume":1}
```
Actual critical delivery requires Apple critical-alert entitlement in the signed app.

Communication/avatar request:
```json
{
  "title": "Taylor",
  "body": "Can you check deploy-main?",
  "thread_id": "deploy-main",
  "communication": {
    "senderName": "Taylor",
    "senderId": "user-123",
    "conversationId": "ops",
    "conversationName": "Ops",
    "avatarUrl": "https://example.com/avatar.png"
  }
}
```
Avatar URL must be HTTPS. Full iOS communication treatment requires app capability plus Notification Service Extension.

Hosted question/select prompt:
```json
{
  "title": "Ship it?",
  "body": "The build passed. Choose the next step.",
  "thread_id": "deploy-main",
  "question": {
    "kind": "select",
    "options": ["Ship", "Hold", {"id": "rollback", "title": "Rollback"}],
    "otherTitle": "Other"
  }
}
```

Hosted text question:
```json
{
  "title": "Need a note",
  "body": "What should the release note say?",
  "question": {"kind": "text", "placeholder": "Type a note"}
}
```

`question` makes Plush host a one-time callback URL and returns `question.id`; poll `GET /v1/questions/:id` until `response` is non-null. Text questions are open-ended. Select questions show up to three native option buttons plus an open-ended text reply by default; set `allowTextResponse: false` only when the user must pick a fixed option. The app stores the prompt and response in local history. Responses can be written with the generated callback token, or with `POST /v1/questions/:id/responses` using the account Bearer token.

Callback actions/replies:
```json
{
  "title": "Deploy needs you",
  "body": "Approve production release?",
  "thread_id": "deploy-main",
  "callback": {
    "url": "https://example.com/plush/reply",
    "method": "POST",
    "actions": [
      {"id": "approve", "title": "Approve"},
      {"id": "reject", "title": "Reject", "destructive": true}
    ]
  }
}
```

Text reply:
```json
{
  "title": "Question",
  "body": "What should the deploy note say?",
  "callback": {
    "url": "https://example.com/plush/reply",
    "text": {"title": "Reply", "buttonTitle": "Send", "placeholder": "Type a note"}
  }
}
```

Callback URLs must be HTTPS. The app calls the endpoint from the device with JSON fields `pushId`, `threadId`, `actionId`, `actionTitle`, optional `responseText`, `notificationTitle`, `notificationBody`, and `sentAt`. Use opaque one-time URLs or headers if the endpoint needs authentication; callback metadata is part of the APNs payload.

Encrypted app-only payloads:
```json
{
  "to": "iphone",
  "title": "Push",
  "body": "Encrypted message",
  "encrypted": {
    "iphone": {
      "algorithm": "x25519-hkdf-sha256-aes-gcm",
      "ciphertext": "...",
      "nonce": "...",
      "tag": "...",
      "ephemeralPublicKey": "...",
      "sentAt": "2026-06-14T00:00:00Z"
    }
  }
}
```

If `open_url` is omitted, tapping the notification opens Plush and the local detail sheet.

## Server-rendered Widgets
`GET /v1/widgets` lists the current cloud widget library. `POST /v1/widgets` or `POST /v1/pushes` with top-level `widget` creates, updates, or deletes a server-rendered widget. Plush persists widgets in the API backend, syncs them into the app/widget extension, and sends official WidgetKit push notifications using `apns-push-type: widgets`, `apns-topic: <bundle>.push-type.widgets`, and `{"aps":{"content-changed":true}}`.

Limits: 100 active widgets per account, 10 KB max rendered widget JSON. Payloads larger than APNs' 4 KB limit are delivered by sync hint: the app/widget fetches the cloud copy. `refresh` can be `auto` (default WidgetKit push + background sync fallback), `widgetkit`, `background`, `urgent` (also sends a visible alert for important updates), or `none`.

Scripted widgets: include `widget.script` to store JS/TS/JSX/TSX source plus an optional compiled JavaScriptCore bundle. The rendered widget fallback must still be valid and under 10 KB. Script source and compiled bundle are each limited to 64 KB; props are limited to 4 KB. The iOS runtime exposes JSX helpers, `fetch`, `URL`, `Request`, `Response`, `Headers`, `Blob`, `ReadableStream`, `console`, scoped `localStorage`, optional scoped `keychain`, and `process` device/runtime info. Storage scopes to `script.groupId` when present, otherwise the widget id. Prefer the SDK/CLI/MCP compiler instead of hand-writing `compiled`.

```json
{
  "widget": {
    "id": "codex-dashboard",
    "title": "Codex",
    "family": "systemMedium",
    "layout": [{"type":"text","text":"Fallback while the app compiles or script errors"}],
    "script": {
      "language": "tsx",
      "source": "import { Text, Widget } from '@easypush/plush/jsx'; export default function CodexWidget(){ return <Widget id='codex-dashboard' title='Codex'><Text>Ready</Text></Widget> }",
      "compiled": "// generated by @easypush/plush",
      "entry": "default",
      "groupId": "codex",
      "props": {"progress": 0.72},
      "permissions": {"fetch": true, "keychain": false}
    }
  }
}
```

```json
{
  "to": "iphone",
  "widget": {
    "id": "deploy-main",
    "action": "update",
    "refresh": "urgent",
    "title": "Deploy",
    "subtitle": "main",
    "detail": "Production rollout",
    "open_url": "https://status.example.com/deploy-main",
    "progress": 0.68,
    "buttons": [
      {"id":"ack","title":"Ack","icon":"checkmark","callback":{"url":"https://example.com/plush/widget/ack"}},
      {"id":"rollback","title":"Rollback","icon":"arrow.uturn.backward","callback":{"url":"https://example.com/plush/widget/rollback"}}
    ],
    "layout": [
      {"type":"row","children":[
        {"type":"badge","text":"production","icon":"shield.checkered","tone":"success"},
        {"type":"metric","label":"ETA","value":"4m"}
      ]},
      {"type":"progress","label":"Rollout","value":"68%","progress":0.68},
      {"type":"list","items":[
        {"text":"API worker healthy","icon":"checkmark.circle.fill","tone":"success"},
        {"text":"Docs cache warming","icon":"doc.text.fill","tone":"secondary"}
      ]}
    ]
  }
}
```

Delete a widget from devices:
```json
{"to":"all","widget":{"id":"deploy-main","action":"delete","title":"Deploy"}}
```

Other good widget use cases:
- backup progress: `id=photo-backup`, `family=systemMedium`, `families=["systemMedium","systemLarge"]`, progress, file counts, and a status URL.
- home or lab sensors: `id=home-sensors`, `family=systemSmall`, `families=["systemSmall","systemMedium"]`, gauge/list blocks, and `plush://widget/home-sensors` for in-app details.
- agent dashboards: Codex can update one widget per task or repo while it works, then delete or mark complete when done.

JSX templates for widgets/Live Activities:
```tsx
import { Badge, Progress, Row, Text, Widget } from "@easypush/plush/jsx";
export default function DeployWidget({ progress = 0.68 }) {
  return <Widget id="deploy-main" action="update" title="Deploy">
    <Row><Text weight="bold">Production deploy</Text><Badge tone="success">healthy</Badge></Row>
    <Progress value={progress} label="Rollout" />
  </Widget>;
}
```
```sh
plush widget --file .plush/deploy-widget.tsx --props-json '{"progress":0.68}'
plush widget update deploy-main --title Deploy --progress 0.68 --refresh urgent
plush live --file .plush/deploy-live.tsx --props-json '{"progress":0.42}'
```

Widgets share the Live Activity JSON renderer and support system small/medium/large plus lock-screen accessory families. Users choose a saved Plush widget from the generic Home Screen widget configuration. `open_url` controls widget taps. `buttons`/ `actions` render WidgetKit buttons; each button can include `callback.url`, `method`, `headers`, and `body`. Callback URL must be HTTPS. JavaScript scripting is done with the `@easypush/plush` JSX/JS template renderer in SDK/CLI/MCP; the server stores rendered JSON and does not execute arbitrary widget JavaScript.

## Live Activities / Dynamic Island
ActivityKit tokens are separate from APNs device tokens. The app registers update tokens after starting a Live Activity and push-to-start tokens on iOS 17.2+. Raw ActivityKit tokens are never returned by list APIs.

Register ActivityKit token, app-only:
```json
{
  "kind": "update",
  "token": "hex-activitykit-token",
  "installationId": "device-local-uuid",
  "activityId": "activity-id",
  "activityName": "deploy-main",
  "apnsEnvironment": "production",
  "attributesType": "PlushLiveActivityAttributes"
}
```

Update an existing Live Activity:
```json
{
  "event": "update",
  "name": "deploy-main",
  "title": "Deploy",
  "status": "Running checks",
  "detail": "main is live; smoke tests are still running.",
  "progress": 0.42,
  "items": ["Worker deployed", "iOS build uploaded"]
}
```

End an activity:
```json
{"event":"end","name":"deploy-main","title":"Deploy","status":"Shipped","progress":1}
```

Start from a push-to-start token:
```json
{
  "event": "start",
  "name": "backup",
  "title": "Backup",
  "status": "Copying files",
  "progress": 0.1,
  "items": ["Archive opened"],
  "inputPushToken": true
}
```

Optional `layout` / `blocks` array for customizable Live Activity, Dynamic Island, and widget rendering:
- block types: `text`, `markdown`, `badge`, `progress`, `gauge`, `row`, `stack`, `grid`, `list`, `metric`, `image`, `divider`, `spacer`.
- common fields: `text`, `label`, `value`, `icon`, `url`, `alt`, `shape`, `role`, `weight`, `tone`, `foreground`, `background`, `align`, `size`, `progress`, `lines`, `columns`, `spacing`, `padding`, `cornerRadius`, `children`, `items`.
- tones: `primary`, `secondary`, `success`, `warning`, `danger`, `accent`.
- roles: `title`, `headline`, `body`, `caption`, `mono`.

```json
{
  "event": "update",
  "name": "deploy-main",
  "title": "Deploy",
  "status": "Running checks",
  "progress": 0.42,
  "layout": [
    {"type":"badge","text":"production","tone":"success"},
    {"type":"progress","label":"Rollout","value":"42%","progress":0.42},
    {"type":"list","items":[
      {"text":"Worker deployed","tone":"success"},
      {"text":"iOS build uploading","tone":"warning"}
    ]}
  ]
}
```

Live Activity payloads are sent to APNs with `apns-push-type: liveactivity` and must fit within APNs' 4 KB payload limit. iOS may truncate Live Activities taller than roughly 160 pt, so Plush renders a capped glanceable subset on lock screen/Dynamic Island. After a delivered `"event":"end"`, Plush marks the update token ended; later updates to the completed activity return no matching token instead of a misleading successful no-op.

## Errors
JSON shape: `{"error":{"message":"...","details":{}}}`.
- 400 invalid body/query.
- 401 missing/invalid bearer token.
- 402 hosted API needs active subscription.
- 404 no matching device.
- 422 encrypted payload missing for target device.
- 503 APNs not configured.
