TypeScript SDK for Simplepush.
ESM-only. Targets Node 20+, Bun, Deno, and modern browsers (browser usage requires a custom webSocketFactory since the standard WebSocket API can't send headers).
bun add @simplepush/sdk
# or
npm install @simplepush/sdkimport{Client}from"@simplepush/sdk";constclient=newClient({apiToken: process.env.SP_API_TOKEN!,// requiredpasswords: [["hunter2","my-topic"]],// [password, topic] pairs; or "account-pw" for the default});forawait(consteventofclient.events({since: newDate(Date.now()-86_400_000).toISOString()})){console.log(event);}Two parallel aggregates: sendTask (multiple inputs, replies) and
sendNotification (a single text/choice/actions input, no replies). Both take a
single options object; the send target (topic for a personal Client, or
topic / member / broadcast on an OrgClient) lives in that object. Omit
the target on a personal Client to send to your own devices (a note-to-self;
encrypted under the account default password when one is configured).
Ids are type-prefixed strings.
taskId,subtaskId, input/reply/file ids and the like come back type-tagged —tsk_…,sub_…,inp_…,rfl_…— not bare UUIDs.
import{Client}from"@simplepush/sdk";constclient=newClient({apiToken: process.env.SP_API_TOKEN!});// A task send returns a TaskGroup: every recipient gets their OWN independent// task instance (one recipient's answers never touch another's task).constgroup=awaitclient.sendTask({topic: "deploys",title: "Deploy v1.2.3?",inputs: [{type: "choice",options: ["yes","no"],required: true}],});consttask=group.sole;// single-recipient topic; iterate group.instances for manyforawait(constevoftask.inputs()){if(ev.kind==="taskCompleted")console.log(ev.uploads);}// A notification: lighter — a single input, one completion event. A personal// topic send defaults to independent mode, so this returns a NotificationGroup// (pass `shared: true` to get one shared Notification instead).constnotifGroup=awaitclient.sendNotification({topic: "deploys",title: "Build failed",content: "main @ a1b2c3 failed 3 tests",input: {type: "choice",options: ["ack","mute"]},});forawait(constevofnotifGroup.inputs()){console.log(ev.item.reply);// NotificationTextReply | NotificationChoiceReply | NotificationActionReply | undefined}Task inputs: text, choice (set multi: true, with optional
minSelections/maxSelections), actions (styled buttons; the tapped
action's stable key comes back), slider (min/max/step/unit),
photo, voiceRecording, file, location. A notification input is text,
choice, or actions; a notification can also carry ONE media item — image
(iOS + Android) or audio (iOS only), an uploaded FileAttachment or a URL
string — and critical: true (iOS Critical Alert).
// Richer inputs: action buttons, a slider, multi-choice, a photo request.constincident=awaitclient.sendTask({topic: "ops",title: "Incident 4711",inputs: [{type: "actions",required: true,actions: [{key: "ack",label: "Acknowledge",style: "primary"},{key: "escalate",label: "Escalate",style: "destructive"},]},{type: "slider",min: 0,max: 10,step: 1,unit: "sev",required: true},{type: "choice",options: ["db","api","infra"],multi: true,required: false},{type: "photo",required: false},],});forawait(constevofincident.inputs()){if(ev.item.kind!=="taskCompleted")continue;for(constuofev.item.uploads){if(u.kind==="action")console.log(ev.recipient?.name,"pressed",u.key);if(u.kind==="slider")console.log("severity",u.value);if(u.kind==="multiChoice")console.log("areas",u.values);if(u.kind==="photo")awaitu.save("./incident-4711");}}By default each filled input arrives as an intermediate input event, then the
terminal taskCompleted carries the full committed set; autoCommit: false
has the recipient submit the whole form at once instead. Other send options:
reply: "one-shot" | "sticky" | "one-time-per-user" opts recipients into the
in-thread reply composer (collect via replies()), and
contentFormat: "markdown" renders content as Markdown.
Attachments.files uploads local files alongside a task/subtask
(encrypted when the send is); the SDK is environment-agnostic, so you pass the
bytes. A notification takes its single media item the same way, or as a URL:
import{readFile}from"node:fs/promises";awaitclient.sendTask({topic: "reports",title: "Q3 numbers",content: "Full report attached.",files: [{filename: "q3.pdf",data: awaitreadFile("q3.pdf"),contentType: "application/pdf"}],});awaitclient.sendNotification({topic: "alerts",title: "Door cam",image: "https://cam.example/last.jpg"});Streams accept { replay, idleMs, signal }: replay emits the buffered backlog
since the send, idleMs ends the stream after that many ms of silence, and
signal cancels it.
File downloads. The binary upload objects (photo/voice/file uploads and a
reply's photo/file/audio) are download handles bound to the client that yielded
them: read() returns the bytes (checksum-verified, decrypted on encrypted
chains), save(path) writes to disk (a directory uses the file's own name),
and downloadUrl() presigns the raw short-lived (~5 min) S3 URL. Downloads
authenticate with the client's apiToken/apiKey and are unlimited. Failures
throw DownloadError.
forawait(constevoftask.inputs()){if(ev.kind==="taskCompleted"){for(constuofev.uploads){if(u.kind==="photo")awaitu.save("./captures");}}}forawait(constroftask.replies()){if(r.kind==="reply"&&r.photo)console.log(awaitr.photo.read());}Encryption. Pass password per send to encrypt the
body; the returned handle decrypts the recipient's replies. Or configure the
client's passwords with [password, topic] pairs — sends to those topics
encrypt automatically (a per-send password overrides):
constclient=newClient({ apiToken,passwords: [["hunter2","deploys"]]});awaitclient.sendTask({topic: "deploys",content: "🤫"});// encrypted via the "deploys" pairawaitclient.sendTask({topic: "other",content: "!",password: "x"});// per-send passwordA task can have subtasks appended to its chain. A subtask inherits the
parent's recipients and encryption (no target, no password); its inputs() /
replies() are scoped to it, and stream off the same shared connection.
constsub=awaittask.append({title: "One more thing",inputs: [{type: "text",required: true}],});forawait(constevofsub.inputs()){if(ev.kind==="subtaskCompleted")console.log(ev.uploads);}By default every recipient of a task send gets their own independent instance,
grouped under a TaskGroup:
constgroup=awaitclient.sendTask({topic: "deploys",content: "check in"});group.groupId;// grptsk_… — the group handlefor(consttaskofgroup.instances){// a full per-recipient Task: its own taskId, appendToken, streamsconsole.log(task.taskId,task.recipient?.publicId,task.recipient?.name);}group.append(...) appends a subtask to every member's chain atomically and
returns one Subtask per member; pass instances: [taskId, …] to reach only
some members. File attachments are uploaded ONCE for the whole batch — every
sibling subtask references the same attachment.
constsubs=awaitgroup.append({content: "follow-up"});// all membersawaitgroup.append({content: "just you",instances: [group.instances[0]!.taskId]});// a subsetPass shared: true to sendTask for shared mode — ONE task all
recipients see and answer together, returned as a plain Task.
activity() (on Task and TaskGroup) merges input events AND replies over
one subscription. To collect from a FRESH process, rebuild an observe-only
handle from persisted ids: client.watchTaskGroup({ groupId, createdAt, members }) / client.watchNotificationGroup(...) — same streams, but no
tokens and no append(); pass createdAt to backfill everything since the
send.
A submission is self-authored user content — a text body plus an optional
photo and file — pushed into a user's own stream with no
associated task; a task reply without the task. Submissions are created by the
app; the SDK observes them on the client's feed (both Client and
OrgClient):
forawait(constsubofclient.submissions({idleMs: 300_000})){// sub: Submission — body?: TextBody, photo?/file?: SubmissionFileif(sub.photo)awaitsub.photo.save("./inbox");}photo/file/audio are download handles (read() / save() / downloadUrl()). Audio attachments carry durationSeconds (voice-recording length in seconds). Location data (latitude, longitude, accuracy, altitude, heading, speed, timestamp) is provided inline via location when available; when the parent is encrypted it is decrypted with the same key as the body.
since (ISO 8601) resumes from that point, backfilling earlier submissions;
idleMs ends the stream after that many ms of silence; signal cancels it.
Encrypted submissions are decrypted with your account default password (not
a topic password). Pass it in passwords (a bare string):
constclient=newClient({apiToken: TOKEN,passwords: "your-default-password"});Or pass it per call (personal Client only): client.submissions({ password })
overrides the account password for that call.
OrgClient authenticates with the org apiKey and addresses sends with
exactly one target: { topic }, { member } (by member name), or
{ broadcast: true }. Encryption is automatic: pass the org's master key(s)
(from your org's encryption vault; the SDK can't derive them) and every send is
encrypted under the current key — there are no per-send passwords. Without
keys, sends go out in the clear and org ciphertext is left as-is.
import{OrgClient}from"@simplepush/sdk";constorg=newOrgClient({apiKey: process.env.SP_API_KEY!,orgMasterKey: masterKeyBytes,// Uint8Array(32)orgMasterKeyVersion: 3,// or several: orgMasterKeys: [{ version, key }]});constgroup=awaitorg.sendTask({broadcast: true,title: "All hands?",inputs: [{type: "choice",options: ["yes","no"],required: true}],});forawait(constevofgroup.inputs()){console.log(ev.recipient?.name,ev.item.kind);// recipient = the org member}Everything else works as on a personal Client: independent-mode groups (the
member handle/name rides on each instance's recipient), subtasks, streams,
submissions, downloads.