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-Tokenheader. - Organization: the organization API key in an
Api-Keyheader.
Send a notification
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
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:
| Header | Value | Notes |
|---|---|---|
Text-Input | description | Free-form text reply. |
Choice-Input | description;opt1,opt2 | One-of-many. Description part optional. |
Action-Input | description;key=Label:style,... | Tap buttons. Description and :style optional. |
Slider-Input | description;min=0;max=14;step=0.1;unit=pH;default=7 | Only min= and max= required. |
Photo-Input | description | Photo from the camera. Description optional. |
Voice-Recording-Input | description | Voice recording. Description optional. |
File-Input | description | Arbitrary file upload. Description optional. |
Location-Input | description | GPS location. Description optional. |
Optional modifiers:
| Header | Effect |
|---|---|
Tag | Label for receiver-side filtering. |
Priority | How loudly the push interrupts, 1 (silent) to 5 (critical, sounds even on a muted phone). Default 3. See Priority. |
Critical-Volume | Volume of the iOS critical alert sound, greater than 0 and at most 1. Only with Priority: 5. |
Auto-Commit: true | Commit each input as soon as it is filled. By default the task is a form that is submitted once. |
Reply | Attach a reply composer. One of one-shot, sticky, one-time-per-user (see below). |
Expires-At | ISO 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:
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.pdfWait 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:
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:
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.binLong 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:
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:
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:
{"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:
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. /jsonendpoints:"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:
| Code | Status | Meaning |
|---|---|---|
authorization_error | 401 | Missing or wrong credential. |
not_topic_holder | 403 | Personal send to a shared topic you don't hold. |
write_protection_token_required | 401 | The topic is write-protected; send Topic-Auth-Token. |
request_limit_exceeded | 429 | Daily request allowance used up. |
service_unavailable | 503 | Backend briefly unavailable. Retry after Retry-After seconds. |
idempotency_in_flight | 409 | A send with this idempotency key is still running. Retry shortly. |
upload_quota_exceeded | 413 | File too large or storage pool full. |
task_canceled | 409 | The sender canceled the task. Stop retrying the submit. |
task_declined | 409 | The task was declined, either by every recipient or by the acting recipient. Stop retrying. |
task_expired | 409 | The task's deadline passed before it was answered. Stop retrying. |
task_already_completed | 400 | The cancel failed because the task was answered first. |