docs/wiki/3.01-API.md
This reference is the entrypoint for working with Super Productivity's APIs. The app exposes four API systems: the Sync Server REST API (for data synchronization), the Local REST API (for controlling the desktop app from local scripts), the Plugin API (for extending the app), and URL Scheme Actions (for triggering a handful of task actions from outside the app, e.g. an iOS Shortcut). This reference summarizes all four; full request/response schemas and examples live in the repository (Sync Server: packages/super-sync-server/; Plugin API: packages/plugin-api/ and docs/plugin-development.md; Local REST API: src/app/core/electron/local-rest-api-handler.service.ts; URL Scheme Actions: electron/protocol-handler.ts and src/app/features/tasks/util/parse-app-uri-task-action.ts).
The Sync Server is an operation-based synchronization service used by the built-in SuperSync provider. It is not WebDAV. See [[2.08-Choose-Sync-Backend]] and [[2.09-Configure-Sync-Backend]] for user-facing sync options.
Protected Sync Server endpoints use JWT Bearer authentication:
Authorization: Bearer <token>. Production account creation and login use
passkeys or emailed magic links; there are no password-based
POST /api/register or POST /api/login routes. Email-only registration starts
at POST /api/register/magic-link.
For the current endpoint purposes, token lifecycle, and security properties, use the authentication authority. The executable routes own exact request and response schemas.
All sync routes require authentication. The stable surface includes operation
upload/download, full-state upload, status, data deletion, restore-point
listing, and restore. POST /api/sync/snapshot is an upload endpoint; there is
no GET /api/sync/snapshot.
Use the
Sync Server architecture
for endpoint purposes and executable owners. Exact routes, limits, error codes,
and wire schemas live in
sync.routes.ts
and the
shared HTTP contract.
The Plugin API is exposed through a global PluginAPI object. Plugins are
privileged extensions, not strongly sandboxed applications: host-side
plugin.js code runs in the app renderer, and iframe plugins are same-origin
with the host. See [[2.21-Manage-Plugins]] for the user trust model and
[[3.05-Web-App-vs-Desktop]] for platform differences. Full types and the
development guide live in packages/plugin-api/src/types.ts and
docs/plugin-development.md.
Data — Tasks:
getTasks(), getArchivedTasks(), getCurrentContextTasks() — Read tasks.getSelectedTask() — Read the task selected in the task detail panel, or null.getFocusedTask() — Read the currently focused task row, or null. Row focus is transient and is cleared when focus moves elsewhere, including into iframe side panels; use getSelectedTask() for persistent side-panel context.addTask(taskData), updateTask(taskId, updates), deleteTask(taskId) — Create/update/delete.batchUpdateForProject(request) — Batch create/update/delete/reorder for a project.reorderTasks(taskIds, contextId, contextType) — Reorder tasks.Data — Projects:
getAllProjects(), addProject(projectData), updateProject(projectId, updates).Data — Tags:
getAllTags(), addTag(tagData), updateTag(tagId, updates).Data — Simple counters:
setCounter(id, value), getCounter(id), incrementCounter(id, incrementBy), decrementCounter(id, decrementBy), deleteCounter(id), getAllCounters().UI:
showSnack(snackCfg), notify(notifyCfg) — Notifications.openDialog(dialogCfg) — Opens a plugin dialog and resolves to the clicked button label, or undefined if the dialog is dismissed. The host sanitizes htmlContent against an allowlist, keeping semantic HTML, form controls and layout styles while removing scripts, event handlers, unsafe URLs, inline <svg>, and styles containing url(.showIndexHtmlAsView() — Shows plugin UI.Registration (main plugin context only; not in iframe):
registerHeaderButton(config), registerMenuEntry(config), registerShortcut(config), registerSidePanelButton(config), registerHook(hook, handler).Persistence:
persistDataSynced(dataStr), loadSyncedData(), getConfig() — Plugin-specific storage and config.Advanced:
executeNodeScript(request) — Run Node.js scripts (Electron only, nodeExecution permission and user consent).dispatchAction(action) — Dispatch NgRx actions (allowed subset).downloadFile(filename, data), isWindowFocused(), onWindowFocusChange(handler).Plugins can register handlers for: taskCreated, taskComplete, taskUpdate, taskDelete, currentTaskChange, finishDay, languageChange, persistedDataChanged, action, anyTaskUpdate, projectListUpdate. Payload types are defined in plugin-api types (HookPayloadMap).
persistedDataChanged fires after the initial boot load on any change to this plugin's persisted data (local write, remote sync, bulk import). The payload is void; re-call loadSyncedData(key?) for fresh data. No replay-on-register, no ordering guarantee across rapid changes — handlers must be idempotent.
Core types (Task, Project, Tag, ProjectFolder, etc.) and batch types (BatchUpdateRequest, BatchUpdateResult, BatchOperation, etc.) are in packages/plugin-api/src/types.ts.
Plugins require manifest.json. Use
PluginManifest
as the current field contract rather than copying its required and optional
fields into the wiki.
Plugin packages normally include plugin.js for host-side behavior. Iframe-only plugins may omit plugin.js when
iFrame: true is set and index.html is included.
executeNodeScript() (Electron only).
Built-in and uploaded plugins may request it. The Electron main process shows
a native consent dialog before granting access; uploaded plugins are labeled
as unverified third-party code with full machine access.Iframe plugins receive a filtered window.PluginAPI object injected into
index.html. That object is the supported interface for task/project/tag APIs,
dialogs, notifications, navigation, persistence, registration, counters, and
action dispatch. executeNodeScript is proxied through the host bridge when
the desktop app grants nodeExecution.
The iframe uses allow-same-origin, which is required by the packaged desktop
app. Same-origin iframe code can reach the parent directly, so the filtered
postMessage bridge is a convenience and compatibility interface, not a
security boundary. See docs/plugin-development.md for the current API surface
and security considerations.
The Local REST API allows external scripts and tools to interact with a running Super Productivity desktop app. It runs on http://127.0.0.1:3876 and is disabled by default. Enable it in Settings → Misc → Enable local REST API.
For development scripts, SP_FORCE_LOCAL_REST_API=1 npm start starts the local REST API without changing the persisted user setting. This override only works in NODE_ENV=DEV. On a clean profile, set SP_FORCE_LOCAL_REST_API_TOKEN=<token> to choose the Bearer token explicitly; otherwise the app generates a temporary development token and prints it to the terminal (stdout, never the exportable app log).
Every request must carry the access token as a Bearer token:
# Keep the token out of the command line: arguments are visible to every process
# on the machine (`ps`, /proc). Put the header in a file only you can read.
(umask 077; printf 'Authorization: Bearer %s\n' "$SP_TOKEN" > ~/.sp-api-header)
# --noproxy is not optional: curl honours http_proxy/ALL_PROXY even for
# 127.0.0.1, so without it a configured proxy receives your token.
curl --noproxy 127.0.0.1 -H "@$HOME/.sp-api-header" http://127.0.0.1:3876/tasks
-H @file needs curl 7.55.0 or newer. On older versions put the same line in a
0600 config file as header = "Authorization: Bearer <token>" and pass it with
--config. Either way the credential never reaches the process table.
The token is generated automatically when you enable the API, and is shown under Settings → Misc → Access Token, where you can also regenerate it. Regenerating invalidates the previous token immediately.
A missing, malformed, or wrong token returns 401 with the UNAUTHORIZED error code and a
WWW-Authenticate: Bearer header. GET /health is the only exception and stays
unauthenticated so tooling can probe liveness.
The examples below omit the
Authorizationheader for brevity — every one of them needs it except/health.
The token is stored per device in a 0600 file inside the
[[3.06-User-Data]] folder and is never synced or exported. It closes the
case where another account on the machine, or a sandboxed process that cannot read
that file, talks to the API: before, every local process could read and modify all of
your tasks.
Two limitations are deliberate, and worth knowing before you enable the API:
127.0.0.1:3876 while Super Productivity is not listening —
before the app starts, or while the API is switched off — receives the Authorization
header of the next client that connects, and can release the port and reuse that token
afterwards. GET /health is unauthenticated, so an impostor can also answer a liveness
probe convincingly. Closing this would require the server to prove its identity over a
protected transport; retrying or moving the port does not help, because the impostor
binds it while the app is down.So: enable the API on a machine where you trust every account that can bind a loopback port, and treat the token as a credential — regenerate it if it may have been exposed.
http://127.0.0.1:3876
All responses are JSON with a consistent envelope:
// Success
{ "ok": true, "data": <response data> }
// Error
{ "ok": false, "error": { "code": "<ERROR_CODE>", "message": "<description>" } }
| Method | Path | Description |
|---|---|---|
| GET | /health | Check if server is running and renderer is ready |
Response:
{ "ok": true, "data": { "server": "up", "rendererReady": true } }
| Method | Path | Description |
|---|---|---|
| GET | /tasks | List tasks (with optional filters) |
| GET | /tasks/:id | Get task by ID |
| POST | /tasks | Create task |
| PATCH | /tasks/:id | Update task |
| DELETE | /tasks/:id | Delete task |
| POST | /tasks/:id/start | Start task (set as current) |
| POST | /tasks/:id/archive | Archive task |
| POST | /tasks/:id/restore | Restore archived task |
GET /tasks Query Parameters:
| Parameter | Type | Description |
|---|---|---|
query | string | Filter by title (case-insensitive, contains) |
projectId | string | Filter by project ID |
tagId | string | Filter by tag ID. Use TODAY for tasks scheduled for today |
includeDone | boolean | Include completed tasks (default: false) |
source | string | "active" | "archived" | "all" (default: "active") |
Examples:
# List all active tasks
curl http://127.0.0.1:3876/tasks
# List archived tasks
curl "http://127.0.0.1:3876/tasks?source=archived"
# Search tasks containing "meeting"
curl "http://127.0.0.1:3876/tasks?query=meeting"
# List tasks scheduled for today
curl "http://127.0.0.1:3876/tasks?tagId=TODAY"
# Create a task
curl -X POST http://127.0.0.1:3876/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries", "projectId": "INBOX_PROJECT"}'
# Create a subtask (parent must be a top-level task; the subtask inherits
# the parent's projectId and cannot have its own tags)
curl -X POST http://127.0.0.1:3876/tasks \
-H "Content-Type: application/json" \
-d '{"title": "milk", "parentId": "PARENT_TASK_ID"}'
# Update a task
curl -X PATCH http://127.0.0.1:3876/tasks/task-id \
-H "Content-Type: application/json" \
-d '{"title": "Updated title"}'
# Archive a task
curl -X POST http://127.0.0.1:3876/tasks/task-id/archive
POST /tasks body fields:
| Field | Notes |
|---|---|
title (required) | Non-empty string |
parentId | Create the task as a subtask of this top-level task. Returns 404 if parent missing, 400 if parent is itself a subtask. The new subtask inherits the parent's projectId and never has its own tags — supplying projectId or tagIds together with parentId returns 400 UNSUPPORTED_FIELD. |
subTaskIds | Not supported on create — returns 400 UNSUPPORTED_FIELD. Create the parent first, then create each child with parentId. |
| other allowed fields | notes, isDone, timeEstimate, timeSpent, projectId, tagIds, dueDay, dueWithTime, plannedAt |
PATCH /tasks/:id — restricted fields:
parentId and subTaskIds cannot be set via PATCH (returns 400 UNSUPPORTED_FIELD). Re-parenting an existing task is not supported by this API; delete and recreate the task instead.
Changing projectId on a top-level task moves the task and all of its subtasks atomically. The destination must be an existing active project; missing or archived destinations return 404 PROJECT_NOT_FOUND. A subtask's projectId is inherited from its parent and cannot be changed directly (400 UNSUPPORTED_FIELD); echoing its unchanged inherited value is accepted for GET-to-PATCH round trips. A project move can be combined with other writable fields in the same PATCH request.
| Method | Path | Description |
|---|---|---|
| GET | /status | Get current task and task count |
| GET | /task-control/current | Get current task |
| POST | /task-control/current | Set current task |
| POST | /task-control/stop | Stop current task |
POST /task-control/current Body:
{ "taskId": "task-id" }
// or to clear:
{ "taskId": null }
Examples:
# Get current task
curl http://127.0.0.1:3876/task-control/current
# Set current task
curl -X POST http://127.0.0.1:3876/task-control/current \
-H "Content-Type: application/json" \
-d '{"taskId": "task-id"}'
# Stop current task
curl -X POST http://127.0.0.1:3876/task-control/stop
| Method | Path | Description |
|---|---|---|
| GET | /projects | List projects |
GET /projects Query Parameters:
| Parameter | Type | Description |
|---|---|---|
query | string | Filter by title (case-insensitive, contains) |
Example:
# List all projects
curl http://127.0.0.1:3876/projects
| Method | Path | Description |
|---|---|---|
| GET | /tags | List tags |
GET /tags Query Parameters:
| Parameter | Type | Description |
|---|---|---|
query | string | Filter by title (case-insensitive, contains) |
Example:
# List all tags
curl http://127.0.0.1:3876/tags
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed, or wrong access token |
TASK_NOT_FOUND | 404 | Task does not exist |
PROJECT_NOT_FOUND | 404 | Target project does not exist or is archived |
INVALID_INPUT | 400 | Invalid request body, or a field has the wrong value type (see error.details) |
UNSUPPORTED_FIELD | 400 | Field cannot be changed through this endpoint |
NOT_FOUND | 404 | Route not found |
INTERNAL_ERROR | 500 | Internal server error |
/health, on top of localhost-only binding.3876; not configurable in v1.Two task actions are reachable through the app's existing custom URL scheme, without opening the app UI first: add a task and complete a task (matched by title). This is intended for external automation — most notably an iOS Shortcut's "Open URLs" action, since Apple Shortcuts has no other lightweight way to reach a non-App-Intents app — but works anywhere a URL can be opened.
Currently iOS and desktop only. Android support is not yet implemented (the app doesn't declare the intent-filters these actions need) and is tracked as a follow-up.
| Platform | Scheme | Example |
|---|---|---|
| iOS (Capacitor) | com.super-productivity.app:// | com.super-productivity.app://create-task?title=Buy%20milk |
| Desktop (Electron) | superproductivity:// | superproductivity://create-task?title=Buy%20milk |
Both schemes are already registered for other purposes (OAuth callbacks on mobile; global-shortcut and window-visibility actions on desktop) — these are two more recognized actions on the same schemes, not a new registration.
Add a task — action create-task on both platforms (desktop also accepts the title as a path segment, create-task/<title>, matching the pre-existing desktop action shipped since v14.2.4 — whose title handling this changes, see the Short Syntax note below). Not add-task: desktop's add-task protocol action already exists and does something unrelated (opens the quick-add-task input bar).
| Query parameter | Required | Description |
|---|---|---|
title | Yes | The new task's title. Surrounding whitespace is trimmed; whitespace-only is rejected (no task is added). Max 300 characters (after trimming). |
notes | No | Task notes. Max 100,000 characters. |
projectId | No | Target project ID. The action fails with an error (no task added) if it doesn't match an existing project. |
Short Syntax is not parsed (see [[3.04-Short-Syntax]]): tokens like +Project, #tag, or @date in the title are stored literally rather than creating/assigning a project, tags, or a due date (surrounding whitespace is still trimmed). A URL is untrusted external content, so — as with the email-drop import — it can't silently create tags/projects or override an explicit projectId. Use the projectId parameter to set the project.
Behavior change: the pre-existing desktop
create-taskaction (shipped since v14.2.4) previously parsed Short Syntax on the title, like a manually typed task. As of this change the title is stored literally on both platforms. PassprojectIdfor the project;+/#/@tokens are no longer interpreted.
Without an explicit projectId, the task is added to whichever project/tag/context is currently active in the app — which on a cold launch is whatever was last open, not necessarily the Inbox. Pass projectId for deterministic placement.
Complete a task — action complete-task on both platforms.
| Query parameter | Required | Description |
|---|---|---|
title | Yes | Matched (trimmed, case-insensitive) against non-subtask, non-done tasks in active (non-archived) projects. Only a lookup needle, never stored, so it is not subject to the create-task length cap. |
An exact case-insensitive title match is preferred; if there is none, a case-insensitive substring match is used, but only when exactly one task matches. If no task matches, or more than one substring match is equally plausible, the action fails with an error rather than guessing.
Examples:
com.super-productivity.app://create-task?title=Buy%20milk¬es=2%25%20fat&projectId=proj-1
com.super-productivity.app://complete-task?title=Buy%20milk
superproductivity://create-task?title=Buy%20milk
superproductivity://complete-task?title=Buy%20milk
Operations and snapshots carry schemaVersion. The current and minimum
supported versions, together with the compatibility policy, are defined in
packages/shared-schema/src/schema-version.ts. Newer schemas are blocked
rather than interpreted using a copied "version skip" allowance.
Sync uses bounded vector clocks for conflict resolution. The canonical size and
pruning rules are in packages/sync-core/src/vector-clock.ts; server-side input
sanitization is in packages/super-sync-server/src/sync/sync.types.ts. The
server may temporarily accept a larger clock during conflict resolution before
pruning it, so an input-validation limit is not the stored clock size.
validatePayload in sync.types.ts./health. For automation and scripting. See [[3.05-Web-App-vs-Desktop]] for platform differences.packages/super-sync-server/; Plugin API and examples in packages/plugin-api/, docs/plugin-development.md, and example plugins under packages/plugin-dev/; Local REST API types in electron/shared-with-frontend/local-rest-api.model.ts.