Plush API
Send customizable, encrypted push notifications to every linked Apple device or one device by name.
npm install @easypush/plushexport 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"
}
}
}
omit to, "all", or { "kind": "all" }"workPhone", ["workPhone"], or { "kind": "deviceNames", "names": ["workPhone"] }{ "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.