Skip to content

cURL ​

Everything the CLI and SDKs do goes over a plain HTTP API, so curl alone is enough for scripts and quick experiments. Sends use simple request headers. If you prefer JSON bodies, every endpoint has a /json version that takes the same fields as a JSON object.

Authentication ​

Every request needs a credential. There are no anonymous sends.

  • Personal: your API token (app settings, under API Token) in an API-Token header.
  • Organization: the organization API key in an Api-Key header.

Send a notification ​

bash
curl -i -X POST https://api.simplepu.sh/v1/notifications \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Topic: builds" \
  -H "Title: Build succeeded" \
  -H "Content: main @ abc123 deployed in 47s"

The response returns the id in the X-Notification-Id header. By default it is a grpntf_ group id, because each recipient gets their own instance. With Shared: true it is a single ntf_ id.

Response headers

The ids and tokens come back as response headers, and curl does not print those by default. Add -i to see them above the body. To capture a single header in a script, use curl -s -o /dev/null -w '%header{x-notification-id}' ... (curl 7.83 or newer).

Omit the Topic header entirely to send to your own devices: the notification goes to every device on your own account.

Send a task ​

bash
curl -i -X POST https://api.simplepu.sh/v1/tasks \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Topic: deploys" \
  -H "Title: Approve deploy?" \
  -H "Choice-Input: Production deploy of v2.1.0;Approve,Reject"

Add one input header to ask for an answer:

HeaderValueNotes
Text-InputdescriptionFree-form text reply.
Choice-Inputdescription;opt1,opt2One-of-many. Description part optional.
Action-Inputdescription;key=Label:style,...Tap buttons. Description and :style optional.
Slider-Inputdescription;min=0;max=14;step=0.1;unit=pH;default=7Only min= and max= required.
Photo-InputdescriptionPhoto from the camera. Description optional.
Voice-Recording-InputdescriptionVoice recording. Description optional.
File-InputdescriptionArbitrary file upload. Description optional.
Location-InputdescriptionGPS location. Description optional.

Optional modifiers:

HeaderEffect
TagLabel for receiver-side filtering.
PriorityHow loudly the push interrupts, 1 (silent) to 5 (critical, sounds even on a muted phone). Default 3. See Priority.
Critical-VolumeVolume of the iOS critical alert sound, greater than 0 and at most 1. Only with Priority: 5.
Auto-Commit: trueCommit each input as soon as it is filled. By default the task is a form that is submitted once.
ReplyAttach a reply composer. One of one-shot, sticky, one-time-per-user (see below).
Expires-AtISO 8601 deadline, must be in the future. Once it passes, an unanswered task expires. Recipients can no longer answer, and late submits fail with task_expired.

Reply modes:

  • one-shot: the first reply closes the composer for everyone.
  • sticky: any device may reply any number of times.
  • one-time-per-user: each user may reply once per round. Others can still reply until each of them has had a turn.

The response carries X-Task-Id (grptsk_ group by default, tsk_ with Shared: true) and X-Append-Token for appending subtasks later.

Attach a file ​

Send the raw bytes as the request body, with Attachment naming the file. Entries with a URL scheme (https://..., or an app's deep link like unifi-protect://...) become link attachments instead:

bash
curl -X POST https://api.simplepu.sh/v1/tasks \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Topic: deploys" \
  -H "Title: Signed contract" \
  -H "Attachment: contract.pdf" \
  --data-binary @./contract.pdf

Wait for the answer inline ​

Add Wait: true to a send with exactly one input. The connection then stays open until the first recipient answers, and the answer is the response body. The server sends a heartbeat newline every 30 seconds so proxies don't drop the connection. Strip newlines before use:

bash
RESULT=$(curl -s -X POST https://api.simplepu.sh/v1/tasks \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Topic: deploys" \
  -H "Choice-Input: Approve deploy?;Approve,Reject" \
  -H "Wait: true" | tr -d '\n')

echo "User chose: $RESULT"

For file, photo, and voice answers the body is a presigned download URL. Capture it, then curl the URL to fetch the file. The URL needs no API-Token header and is valid for about 5 minutes:

bash
URL=$(curl -s -X POST https://api.simplepu.sh/v1/tasks \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "File-Input: Upload the file" \
  -H "Wait: true" | tr -d '\n')

curl -s "$URL" -o upload.bin

Long waits

NATs and load balancers can drop an open HTTP connection on very long waits. When that happens, the answer is lost to that request. For anything beyond quick approvals, use sp collect or an SDK. They collect over a resumable WebSocket.

Append a subtask ​

Use the X-Append-Token from the original send:

bash
curl -i -X POST https://api.simplepu.sh/v1/subtasks \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Append-Token: $TOKEN" \
  -H "Content: One more approval needed" \
  -H "Choice-Input: Confirm the rollback;Yes,No"

The response returns X-Subtask-Id. For a default send this is the grptsk_ group id, because the subtask is appended to every recipient's own copy of the task. For a Shared: true send it is the sub_ id of the single appended subtask.

Cancel a task ​

Withdraw a pending send. Use the same sender credential as for the create (API-Token, or Api-Key for organization sends). The body is flat JSON:

bash
curl -X POST https://api.simplepu.sh/v1/tasks/tsk_.../cancel \
  -H "API-Token: YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"answered","note":"already handled"}'

reason is canceled (default), answered (another recipient's answer made the rest unnecessary), or superseded (a replacement exists, and supersededBy names its id). note is free text for the recipients.

Two related endpoints use the same shape. POST /v1/subtasks/{sub_...}/cancel withdraws one follow-up and leaves the rest of the chain live. Its supersededBy must be a subtask of the same chain. POST /v1/task-groups/{grptsk_...}/cancel cancels every still-pending instance of a group and returns the counts:

json
{"canceled": 2, "skipped": 1}

Already-finished instances are skipped, never failed. Canceling an already-answered task fails with task_already_completed. Canceling twice is a no-op. On an encrypted send the note must be encrypted with the chain's key, with its marker in an encryption field. From curl, either omit the note and rely on reason, or use the CLI or an SDK, which encrypt the note for you.

Organization sends ​

Address a member by name or every member at once. Both require the organization API key:

bash
curl -X POST https://api.simplepu.sh/v1/notifications \
  -H "Api-Key: YOUR_ORG_API_KEY" \
  -H "Member: Alice" \
  -H "Title: Heads up" \
  -H "Content: Gate 4 is blocked"

curl -X POST https://api.simplepu.sh/v1/notifications \
  -H "Api-Key: YOUR_ORG_API_KEY" \
  -H "Broadcast: true" \
  -H "Title: All hands" \
  -H "Content: Meeting at 3pm, link in your inbox"

Retries and idempotency ​

During a brief backend outage (a database failover) requests return 503 with a Retry-After header. Retry after the given delay. Every create endpoint takes an idempotency key, so that a retried create can never send twice. Generate a random string (a UUID) per logical send and resend it unchanged on every retry. A duplicate returns the original response instead of creating again.

  • Header endpoints: Idempotency-Key: <key> request header.
  • /json endpoints: "idempotencyKey": "<key>" body field.

Keys are scoped to your credential and kept for 24 hours. A concurrent duplicate returns 409 idempotency_in_flight. Treat it like a 503 and retry.

Prefer the CLI or SDKs

The CLI and all SDKs handle this for you. Every send carries an idempotency key and retries 503s transparently. Over the raw HTTP API, retry safety is your job.

Errors ​

Errors come back as { "error": "<code>", "msg": "..." }. The ones worth handling in scripts:

CodeStatusMeaning
authorization_error401Missing or wrong credential.
not_topic_holder403Personal send to a shared topic you don't hold.
write_protection_token_required401The topic is write-protected; send Topic-Auth-Token.
request_limit_exceeded429Daily request allowance used up.
service_unavailable503Backend briefly unavailable. Retry after Retry-After seconds.
idempotency_in_flight409A send with this idempotency key is still running. Retry shortly.
upload_quota_exceeded413File too large or storage pool full.
task_canceled409The sender canceled the task. Stop retrying the submit.
task_declined409The task was declined, either by every recipient or by the acting recipient. Stop retrying.
task_expired409The task's deadline passed before it was answered. Stop retrying.
task_already_completed400The cancel failed because the task was answered first.