# 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
JS SDK/CLI package: easypush

## 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/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"}]
  }
}
```

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

```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/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` 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`, `badge`, `progress`, `gauge`, `row`, `stack`, `grid`, `list`, `metric`, `image`, `divider`, `spacer`.
- common fields: `text`, `label`, `value`, `icon`, `role`, `weight`, `tone`, `foreground`, `background`, `align`, `size`, `progress`, `lines`, `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.
