TypeScript SDK
Installation
npm install @simplepush/sdkRequires Node.js 20+. Bun and Deno work too. The package is ESM-only. Import it with import, require() is not supported. In browsers you must supply a webSocketFactory in the client config, because the SDK defaults to the ws package. End-to-end encryption works out of the box via libsodium.
Clients
The SDK has two clients:
Client: a personal account, authenticated with your API token (app settings, under API Token).OrgClient: an organization, authenticated with the organization API key.
Everything is Promise-based. Sends resolve with a handle. Answers arrive as async iterables that you consume with for await.
import { Client, OrgClient } from '@simplepush/sdk'
const client = new Client({ apiToken: 'YOUR_API_TOKEN' })
const org = new OrgClient({ apiKey: 'YOUR_ORG_API_KEY' })Config
| Option | Client | OrgClient | Description |
|---|---|---|---|
baseUrl | ✓ | ✓ | Server override, default https://api.simplepu.sh. |
apiToken | required | — | Your personal API token. |
passwords | ✓ | — | Encryption passwords, see below. |
apiKey | — | required | The organization API key. |
orgMasterKeys | — | ✓ | Organization encryption keys as [{ version, key: Uint8Array }] (or orgMasterKey plus orgMasterKeyVersion for one). |
webSocketFactory, fetch | ✓ | ✓ | Runtime injection, needed in browsers. |
passwords takes a single string or an array:
const client = new Client({
apiToken: 'YOUR_API_TOKEN',
passwords: [
['alerts-secret', 'alerts'], // [password, topic]: encrypts sends to that topic and decrypts its events
'personal-secret', // bare string: your personal password
],
})A [password, topic] pair becomes the default key for that topic, for sending and receiving. Only one bare string is allowed: the personal password. It decrypts your submissions and encrypts what you send to your own devices. It never encrypts a topic send.
Sending tasks
sendTask takes exactly one target: topic on both clients, or member or broadcast: true on OrgClient only. With no target, a personal Client sends to your own devices and resolves to a single Task.
const group = await client.sendTask({
topic: 'deploys',
title: 'Approve deploy?',
inputs: [{ type: 'choice', options: ['Approve', 'Deny', 'Hold'], required: true }],
})By default every recipient gets their own task instance, and the send resolves to a TaskGroup. When there is exactly one recipient, group.sole gives you that single Task. Pass shared: true to send one task that all recipients see and answer together. That send resolves to a plain Task.
| Option | Type | Description |
|---|---|---|
topic / member / broadcast | targeting | Exactly one, or none to send to your own devices. |
title, content | string? | Task title and body. At least content or inputs is required. |
inputs | Input[]? | Inputs the recipient should fill, see Input types. |
links | string[]? | URL attachments. |
files | FileAttachment[]? | Files to upload and attach: { filename, data: Uint8Array, contentType? }. Encrypted with the send's key before upload. |
autoCommit | boolean? | Complete the task automatically once all required inputs are filled. Default false. Then the task is a form that the recipient submits once. |
password | string? | Password to encrypt this send with. Requires topic. Defaults to the topic's configured password, if there is one. Otherwise the send is unencrypted. Not accepted when sending to your own devices. Those sends always use your personal password. |
reply | 'one-shot' | 'sticky' | 'one-time-per-user'? | Show a reply composer. Omit for none. |
contentFormat | 'plain' | 'markdown'? | 'markdown' renders content as Markdown on the recipient's device. The format marker is never encrypted. Default plain. |
priority | 1 | 2 | 3 | 4 | 5? | How loudly the push interrupts. Default 3. See Priority. |
criticalVolume | number? | Volume of the iOS critical alert sound, greater than 0 and at most 1. Only with priority: 5. |
tag | string? | Label for receiver-side filtering. |
shared | boolean? | One shared task for all recipients instead of one each, see above. Default false. |
expiresAt | Date | string? | Deadline. A Date or an ISO 8601 string, in the future. After the deadline, an unanswered task expires. See Declining and expiry. |
Input types
Inputs are plain objects discriminated on type. All carry required: boolean and an optional description.
| Shape | Use |
|---|---|
{ type: 'text', defaultValue? } | Free-form text reply. |
{ type: 'choice', options } | One-of-many choice. Add multi: true for multi-select, optionally with minSelections / maxSelections. A multi-select never renders as push buttons. The recipient answers in the app. |
{ type: 'actions', actions } | Buttons the recipient taps. actions is { key, label, style? }[]. Keys must be unique. style is 'default', 'primary', or 'destructive'. The chosen key comes back. |
{ type: 'slider', min, max, step?, unit?, defaultValue? } | A number on a [min, max] scale. |
{ type: 'photo' } | Photo from the camera. |
{ type: 'voiceRecording' } | Voice recording. |
{ type: 'file' } | Arbitrary file upload. |
{ type: 'location' } | GPS location from the device. |
Collecting answers
Handles expose async-iterable streams. inputs() yields each filled input. The stream ends with one terminal item: taskCompleted (carrying all uploads), taskDeleted, taskCanceled, taskDeclined, or taskExpired. replies() yields reply-composer replies. activity() interleaves both. All take { replay?, idleMs?, signal? }. replay: true also delivers the backlog since the send. idleMs ends the stream after that much silence. An AbortSignal cancels it.
const group = await client.sendTask({
topic: 'deploys',
title: 'Approve deploy?',
inputs: [{ type: 'choice', options: ['Approve', 'Deny'], required: true }],
})
for await (const { item, recipient } of group.inputs({ idleMs: 600_000 })) {
if (item.kind === 'taskCompleted') {
console.log(recipient?.publicId, 'answered', item.uploads)
}
}A TaskGroup streams all recipients at once. Items are { instance, item, recipient }, where instance is that recipient's own Task. idleMs measures silence across the whole group, so one quiet recipient does not end the stream.
Uploads are discriminated on kind: text (value), choice (index, value), multiChoice (indices, values), action (key), slider (value), location (location), and the downloadable photo / voice / file (see Downloads).
Collecting in another process
Handles come from the send, but collection does not have to happen in the same process. Persist groupId, the instance taskIds, and createdAt, then rebuild a read-only handle later:
const group = client.watchTaskGroup({ groupId, createdAt, members: [{ taskId, recipient }] })
for await (const item of group.replies({ replay: true })) { /* ... */ }Subtasks
Append a follow-up to a task the recipient already has. task.append(...) resolves to a Subtask with its own inputs() / replies() streams. group.append(...) appends to every instance. Pass instances: [...] to append to some instances only. Subtasks accept the same content, inputs, links, and files. They go one level deep only. If you only have the append token from an earlier send, use the stateless client.appendSubtask({ appendToken, ... }).
Canceling
Every send handle can withdraw what it sent. task.cancel(...) and subtask.cancel(...) resolve once the server has accepted the cancel. group.cancel(...) cancels every still-pending instance and resolves to the counts. Already-finished instances are skipped, never failed.
const { canceled, skipped } = await group.cancel({ reason: 'answered', note: 'already handled' })Options:
reason:'canceled'(default),'answered', or'superseded'.note: free text for the recipients. Encrypted under the chain's key when the send was encrypted.supersededBy: the replacement's id. Requiresreason: 'superseded'. A subtask's replacement must be in the same chain. A group cancel takes the replacement group's id, and each instance is pointed at its own recipient's replacement.
On the receiving side, a canceled instance's streams end with a { kind: 'taskCanceled' } item. It carries reason, note, and supersededBy. A canceled subtask yields { kind: 'subtaskCanceled' } on its own streams only. A canceled root task ends every stream of its chain. Canceling an already-answered task rejects with task_already_completed. Canceling twice is a no-op. To cancel by id without a handle, use the stateless cancelTask / cancelSubtask / cancelTaskGroup functions. They take { baseUrl, body, authHeaders }.
Declining and expiry
When a recipient declines in the app, the stream yields a { kind: 'taskDeclinedByRecipient' } signal. A shared task stays open for the other recipients. The signal carries reason ('declined' or 'failed'), an optional note, and an actor that says who declined. When every recipient has declined, the terminal { kind: 'taskDeclined' } item follows and the stream ends. An independent-mode instance has one recipient, so its signal and terminal item arrive together. Subtask declines work the same way with subtaskDeclinedByRecipient / subtaskDeclined, and affect only that subtask.
A task sent with expiresAt expires when its deadline passes without an answer. Its streams end with a { kind: 'taskExpired' } item. Like taskDeclined, this item has no actor and no note, because the deadline was set on the send. An expired or fully-declined root task ends every stream of its chain, exactly like a canceled one.
for await (const { item, recipient } of group.inputs()) {
if (item.kind === 'taskDeclinedByRecipient') console.log(recipient?.name, 'declined:', item.reason, item.note)
if (item.kind === 'taskExpired') console.log(recipient?.name, 'never answered in time')
}Late answers into a declined or expired task reject with task_declined / task_expired.
Sending notifications
sendNotification uses the same targeting and grouping as sendTask, but sends a lighter, fire-and-forget push. It carries at most one input and at most one media item. The media item is either image or audio, never both. Each takes a URL string or a FileAttachment to upload. Notification inputs have their own shapes: { type: 'text' }, { type: 'choice', options }, or { type: 'actions', actions }. Notification action styles are 'default' or 'destructive'.
const group = await client.sendNotification({
topic: 'deploys',
title: 'Restart the gateway?',
input: { type: 'choice', options: ['Yes', 'No'] },
})
for await (const done of group.sole.inputs({ idleMs: 300_000 })) {
console.log(done.reply) // { type: 'choice', selectedIndex: 0, selectedValue: 'Yes' }
}A notification handle has a single stream. inputs() yields exactly one notificationCompleted, with the reply if there is one, and then ends. A group has a single stream too. group.inputs() merges every instance's stream and yields { instance, item, recipient } per answer. It ends once every recipient has answered. idleMs measures silence across the whole group.
Media constraints: images (image/jpeg, image/png, image/gif) render on iOS and Android. Audio plays inline on iOS only.
Submissions
Submissions are content a person sends from the app without being asked: text, a photo, a voice memo, a file, a location. Observe them with client.submissions():
for await (const s of client.submissions()) {
if (s.body) console.log('text:', s.body.text)
if (s.photo) await s.photo.save('./inbox')
}Encrypted submissions are encrypted under your personal password. Configure it in passwords, or pass password per call, to decrypt them. OrgClient submissions decrypt with the organization master keys.
Downloads
Photo, voice, file, and audio objects yielded by any stream are bound download handles:
| Method | Returns |
|---|---|
await x.read() | Uint8Array, checksum-verified and decrypted. |
await x.save(path?) | Writes to disk (a directory uses the file's own name) and returns the path. |
await x.downloadUrl() | { url, expiresAt }, a presigned URL valid for about 5 minutes. Use it to fetch with your own HTTP stack. On an encrypted chain the URL serves the raw ciphertext, so prefer read() / save(), which verify and decrypt. |
Downloads authenticate with the API token (personal) or API key (organization).
Raw events
client.events({ since?, signal?, onReconnect? }) yields every raw Event on the account's stream, not split per send. Events carry prefixed ids (tsk_, sub_, ntf_, sbm_, grptsk_, ...). Treat them as opaque strings. Receive-side events carry an actor ({ publicId, name, devicePublicId, deviceName }) that says who acted. For normal use, prefer the handle streams.
Errors and cleanup
Send failures throw ApiError-shaped errors with the server's { error, msg } body. Stream failures throw after automatic reconnection has been exhausted. Download failures throw DownloadError. Call client.close() to end all active streams and close the shared WebSocket.