Plush

Plush API

Send customizable, encrypted push notifications to every linked Apple device or one device by name.

Base URLhttps://api.easypush.app
Agent reference/doc.md
Codex hook/codex.md
Claude Code hook/claude.md
Widget runtime/runtime.md
Playground/play
SDK / CLInpm install @easypush/plush
export PLUSH_API_TOKEN="push_..."

curl https://api.easypush.app/v1/devices \
  -H "Authorization: Bearer $PLUSH_API_TOKEN"

Auth

The iOS app can sign in with Apple for a family API token, or register one standalone device and store that API key in Keychain. API calls use bearer auth.

POST /v1/auth/apple App-only exchange for a Sign in with Apple identity token.
{
  "identityToken": "eyJhbGciOi..."
}
POST /v1/auth/standalone App-only registration for one device without Sign in with Apple.
{
  "installationId": "device-local-uuid"
}
POST /v1/tokens/rotate Revoke the current bearer token and return a replacement token.

Devices

Devices register their APNs token and encryption public key. Use the app to edit each device name before targeting it from the API.

GET /v1/devices List enabled devices linked to the Apple account.
POST /v1/devices Register or refresh the current device.
{
  "installationId": "device-local-uuid",
  "name": "workPhone",
  "platform": "iOS",
  "pushToken": "apns-token",
  "publicKey": "base64-public-key",
  "publicKeyAlgorithm": "x25519-hkdf-sha256-aes-gcm",
  "appVersion": "1.0",
  "osVersion": "iOS 18"
}
PATCH /v1/devices/:id Rename or disable a device.
DELETE /v1/devices/:id Remove a device.

Pushes

The quickest request sends a visible APNs alert. Add encrypted envelopes only when you need app-only content.

POST /v1/pushes Send a plain text body to all enabled devices.
curl -X POST https://api.easypush.app/v1/pushes \
  -H "Authorization: Bearer $PLUSH_API_TOKEN" \
  -d 'Build passed'
GET /v1/:token Optional webhook-style URL for browser, Shortcut, or limited HTTP clients.
https://api.easypush.app/v1/push_...?message=Build%20passed&title=CI
GET /v1/send Same query API with bearer auth instead of a token in the URL.
POST /v1/send Same push API with bearer auth, useful when systems reserve /pushes paths.
POST /v1/pushes Send JSON to customize all devices, named devices, or explicit device IDs.
{
  "title": "Build passed",
  "body": "main deployed successfully",
  "thread_id": "deploy-main",
  "app": {
    "name": "Codex",
    "iconUrl": "https://chatgpt.com/favicon.ico"
  }
}
{
  "title": "Door left open",
  "body": "Garage sensor has been open for 10 minutes.",
  "interruption_level": "critical",
  "volume": 1
}
{
  "title": "Taylor",
  "body": "Can you check deploy-main?",
  "thread_id": "deploy-main",
  "communication": {
    "senderName": "Taylor",
    "avatarUrl": "https://example.com/avatar.png",
    "conversationName": "Ops"
  }
}
{
  "title": "Deploy needs you",
  "body": "Approve production release?",
  "thread_id": "deploy-main",
  "callback": {
    "url": "https://example.com/plush/reply",
    "actions": [
      {"id": "approve", "title": "Approve"},
      {"id": "reject", "title": "Reject", "destructive": true}
    ]
  }
}
{
  "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"}
  }
}
{
  "to": "workPhone",
  "title": "Build passed",
  "body": "main deployed successfully",
  "encrypted": {
    "workPhone": {
      "ciphertext": "...",
      "nonce": "...",
      "tag": "...",
      "ephemeralPublicKey": "...",
      "sentAt": "2026-06-12T00:00:00Z"
    }
  }
}
Target kindShape
allomit to, "all", or { "kind": "all" }
deviceNames"workPhone", ["workPhone"], or { "kind": "deviceNames", "names": ["workPhone"] }
deviceIds{ "kind": "deviceIds", "ids": ["..."] }

JSON accepts camelCase and snake_case aliases for common APNs fields, including message, thread_id, interruption_level, relevance_score, expiration_date, and open_url. app is local Plush history metadata only; use it for labels like Codex plus an HTTPS icon in the app, not for changing the APNs banner. Plush always clears the app icon badge. Without open_url, tapping a notification opens Plush and shows the full local detail sheet. callback adds notification actions or an inline text reply; the app calls the supplied HTTPS endpoint from the device with the selected action or reply text. Critical alerts and communication avatars also require the matching Apple capabilities in the signed iOS app.

Questions

Use question when Plush should host the response endpoint for you. Text questions show an inline reply, and select questions show up to three native choices plus an open-ended text reply labeled Other by default. The app stores the question and the user's response in local history, while the API exposes the response for polling.

POST /v1/pushes Send a question notification with a hosted response URL.
{
  "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"
  }
}
{
  "title": "Need a note",
  "body": "What should the release note say?",
  "question": {
    "kind": "text",
    "placeholder": "Type a short note"
  }
}
GET /v1/questions List recent questions for the current API token.
GET /v1/questions/:id Read one question and its response, if the user answered.
POST /v1/questions/:id/responses Record a response with Bearer auth, or use the one-time callback URL generated for the notification.
import { PlushClient } from "@easypush/plush";

const plush = new PlushClient();
const response = await plush.askQuestion({
  title: "Ship it?",
  body: "The build passed.",
  question: { kind: "select", options: ["Ship", "Hold"] }
}, { wait: true });

Widgets

Send a server-rendered WidgetKit payload with widget on /v1/widgets or /v1/pushes. Plush stores widgets in the cloud, syncs them into the app/widget extension, and sends official WidgetKit push notifications so Home Screen widgets reload from the latest stored copy.

GET /v1/widgets List the current cloud widget library for the API token.
POST /v1/widgets Create, update, or delete a cloud-persisted server-rendered widget.
POST /v1/widgets/tokens App-only registration for WidgetKit push tokens from the widget extension.
{
  "to": "workPhone",
  "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", "method": "POST"}}
    ],
    "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"}
      ]}
    ]
  }
}
{
  "to": "all",
  "widget": {"id": "deploy-main", "action": "delete", "title": "Deploy"}
}
{
  "widget": {
    "id": "photo-backup",
    "action": "update",
    "title": "Photo Backup",
    "subtitle": "Archive",
    "detail": "Cold storage sync",
    "family": "systemMedium",
    "families": ["systemMedium", "systemLarge"],
    "progress": 0.57,
    "open_url": "https://status.example.com/backups/photo",
    "items": ["1842 files copied", "2.1 GB remaining"],
    "layout": [
      {"type":"row","children":[
        {"type":"badge","text":"backup","icon":"externaldrive.fill","tone":"accent"},
        {"type":"metric","label":"Files","value":"1842"}
      ]},
      {"type":"progress","label":"Archive","value":"57%","progress":0.57},
      {"type":"list","items":[
        {"text":"Photos copied","icon":"photo.stack.fill","tone":"success"},
        {"text":"Cold storage syncing","icon":"arrow.triangle.2.circlepath","tone":"secondary"}
      ]}
    ]
  }
}
{
  "widget": {
    "id": "home-sensors",
    "action": "update",
    "title": "Home",
    "subtitle": "Sensors",
    "family": "systemSmall",
    "families": ["systemSmall", "systemMedium"],
    "open_url": "plush://widget/home-sensors",
    "layout": [
      {"type":"badge","text":"home","icon":"house.fill","tone":"secondary"},
      {"type":"gauge","label":"Humidity","value":"46%","progress":0.46,"tone":"accent"},
      {"type":"list","items":[
        {"text":"Garage closed","icon":"checkmark.circle.fill","tone":"success"},
        {"text":"Kitchen idle","icon":"moon.fill","tone":"secondary"}
      ]}
    ]
  }
}
plush widget --file .plush/deploy-widget.tsx --props-json '{"progress":0.68}'

// .plush/deploy-widget.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>;
}

Users can pick which saved widget appears in the generic Plush Home Screen widget. open_url controls the tap target. buttons render WidgetKit buttons backed by App Intents; each button callback must use HTTPS and is called from the device. Widgets and Live Activities share the same JSON renderer. Blocks include text, markdown, badge, progress, gauge, row, stack, grid, list, metric, image, divider, and spacer, plus layout fields like spacing, padding, cornerRadius, background, and foreground.

Cloud widget limits are 100 active widgets per account and 10 KB per rendered widget payload. refresh accepts auto, widgetkit, background, urgent, or none. auto sends WidgetKit pushes plus the background sync fallback. urgent also sends a visible APNs alert for important changes while WidgetKit reloads silently. JavaScript scripting is supported through the @easypush/plush SDK/CLI/MCP JSX template renderer; the backend stores the rendered JSON, not arbitrary server-executed JavaScript.

Live Activities

Plush can register ActivityKit push tokens and send Live Activity or Dynamic Island updates through APNs. Start a test Live Activity from Settings first, then target it by name.

GET /v1/live-activities List registered push-to-start and update tokens without exposing the raw token.
POST /v1/live-activities/tokens App-only registration for ActivityKit push tokens.
POST /v1/live-activities Start, update, or end Live Activities.
curl -X POST https://api.easypush.app/v1/live-activities \
  -H "Authorization: Bearer $PLUSH_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "update",
    "name": "plush-preview",
    "title": "Deploy",
    "status": "Running checks",
    "progress": 0.42,
    "items": ["Worker deployed", "iOS build uploading"]
  }'
{
  "event": "update",
  "name": "deploy-main",
  "title": "Deploy",
  "status": "Running checks",
  "progress": 0.42,
  "layout": [
    {
      "type": "row",
      "children": [
        {"type": "badge", "text": "production", "tone": "success"},
        {"type": "metric", "label": "ETA", "value": "4m"}
      ]
    },
    {"type": "progress", "label": "Rollout", "value": "42%", "progress": 0.42},
    {
      "type": "list",
      "items": [
        {"text": "Worker deployed", "tone": "success"},
        {"text": "iOS build uploading", "tone": "warning"}
      ]
    }
  ]
}
{
  "event": "end",
  "name": "plush-preview",
  "title": "Deploy",
  "status": "Shipped",
  "progress": 1,
  "dismissal_date": "2026-06-14T18:30:00Z"
}

For push-to-start on iOS 17.2+, send "event":"start" after the app has registered a push-to-start token. Plush adds the required attributes-type, attributes, and content-state APNs fields from the compact JSON body.

The optional layout array supports text, markdown, badge, progress, gauge, row, stack, grid, list, metric, image, divider, and spacer. Keep lock-screen content compact; iOS may truncate Live Activities that exceed roughly 160 pt. Use the playground to preview the native-like renderer before sending.

After a delivered "event":"end", Plush marks that ActivityKit update token ended so future updates to the same completed activity no longer report as successful no-ops.

Hosted Plush

The hosted service runs at https://api.easypush.app. Use the iOS app to start the hosted flow, subscribe, and reveal the API token stored in Keychain.

Pricing is $1 monthly or $10 yearly. The yearly plan includes a one-week trial before billing starts.

Self-host

The server is a small Cloudflare Worker with D1. Custom deployments can set BILLING_MODE=custom to skip hosted subscription checks.

cd server
npm install
npx wrangler d1 create plush-api
npm run db:migrate:remote
npx wrangler secret put APNS_PRIVATE_KEY
npx wrangler deploy

Errors

Errors return JSON with a message and optional details.

StatusMeaning
401Missing or invalid bearer token.
402Hosted API access needs an active subscription.
422Missing encrypted payloads for a target device.
503APNs credentials are not configured.