TypeScript SDK
Installation
npm install @simplepush/sdkRequires Node.js 20+ (also works on Bun and Deno). The package is ESM-only, so import it with import; require() is not supported. In browsers you must supply a webSocketFactory in the client config, since 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 org API key.
Everything is Promise-based. Sends resolve with a handle; answers arrive as async iterables 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 org API key. |
orgMasterKeys | — | ✓ | Org 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 in both directions. The one allowed bare string is the personal password: it decrypts your submissions and encrypts what you send to your own devices, but never encrypts a topic send.
Sending tasks
sendTask targets a topic (both clients) or, on OrgClient, a member or broadcast: true. Omitting the target on 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; use group.sole to unwrap the single Task when there is exactly one recipient. Pass shared: true for one task that all recipients see and answer together, resolved as 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 true. |
password | string? | Password to encrypt this send with. Requires topic. Defaults to the topic's configured password, if any; otherwise the send is unencrypted. Not accepted when sending to your own devices, which always uses 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. Never encrypted; default plain. |
tag | string? | Label for receiver-side filtering. |
shared | boolean? | One shared task for all recipients instead of one each, see above. Default false. |
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 (optionally minSelections / maxSelections) for multi-select; 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? }[] with unique keys and style of '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 and ends with a terminal taskCompleted (carrying all uploads) or taskDeleted. 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, and 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 doesn't 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 doesn't have to happen in the same process. Persist groupId, the member 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 member (or pass instances: [...] to pick some). Subtasks accept the same content, inputs, links, and files, and go one level deep only. There is also a stateless client.appendSubtask({ appendToken, ... }) when all you have is the append token from an earlier send.
Sending notifications
sendNotification uses the same targeting and grouping but sends a lighter, fire-and-forget push. It carries at most one input and at most one media item (image XOR audio, each a URL string or a FileAttachment to upload). Notification inputs are their own shapes: { type: 'text' }, { type: 'choice', options }, or { type: 'actions', actions } where 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 any) and ends. A group has one too: group.inputs() merges every member's stream, yielding { instance, item, recipient } per answer and ending 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 sealed under your personal password; configure it in passwords (or pass password per call) to decrypt them. OrgClient submissions decrypt with the org 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. Escape hatch for your own HTTP stack: on an encrypted chain it serves the raw ciphertext, so prefer read() / save(), which verify and decrypt. |
Downloads authenticate with the API token (personal) or API key (org).
Raw events
client.events({ since?, signal?, onReconnect? }) yields every raw Event on the account's stream, undemuxed. Events carry prefixed ids (tsk_, sub_, ntf_, sbm_, grptsk_, ...) — treat them as opaque strings. Receive-side events carry an actor ({ publicId, name, devicePublicId, deviceName }) attributing who acted. Prefer the handle streams for normal use.
Errors and cleanup
Send failures throw ApiError-shaped errors with the server's { error, msg } body; stream failures throw after transparent reconnection is exhausted; download failures throw DownloadError. Call client.close() to end all active streams and close the shared WebSocket.