HTTP API Reference

Subroutine exposes a RESTful HTTP API that all clients (desktop, Android, Wear OS) use as their sole data layer. There is no proprietary sync protocol — everything goes through these endpoints.

Base URL: https://api.subroutineapp.com

All request and response bodies are JSON. Content-Type: application/json is required on requests with a body.


Authentication

The API is currently unauthenticated. It is intended for private/self-hosted deployments. Authentication will be added in a future release.


Data model

Action states

An Action.state is a tagged JSON object with a type field:

StateJSON
Backlogged (no date){"type": "backlogged", "date": null}
Backlogged (date hint){"type": "backlogged", "date": "2024-01-15"}
Queued, floating{"type": "queued", "time": "2024-01-15T14:00:00Z", "is_static": false}
Queued, static{"type": "queued", "time": "2024-01-15T14:00:00Z", "is_static": true}
Completed{"type": "completed", "at": "2024-01-15T14:05:00Z"}
Skipped{"type": "skipped"}

A floating action's time is managed by the pipeline scheduler. A static action is pinned to its time by the user.

Recurrence

{ "unit": "days" | "weeks" | "months" | "years", "every": 1 }

Action

{
  "id":           "018f1e2a-...",
  "lineage_id":   "018f1e2a-...",
  "routine_id":   null,
  "template_id":  null,
  "title":        "Buy milk",
  "content":      null,
  "duration":     null,
  "recurrence":   null,
  "state":        { "type": "backlogged", "date": null }
}

lineage_id groups recurring instances of the same action. duration is nanoseconds (i64) when present.

ActionTemplate

{
  "id":         "018f1e2a-...",
  "lineage_id": "018f1e2a-...",
  "title":      "Weekly review",
  "content":    null,
  "duration":   null,
  "recurrence": { "unit": "weeks", "every": 1 }
}

Event

{
  "id":         "018f1e2b-...",
  "lineage_id": "018f1e2b-...",
  "template_id": null,
  "title":      "Team standup",
  "content":    null,
  "time":       "2024-01-15T14:00:00Z",
  "duration":   3600000000000,
  "recurrence": null
}

Routine

{
  "id":         "018f1e2c-...",
  "title":      "Morning routine",
  "content":    null,
  "target":     null,
  "recurrence": null,
  "steps": [
    { "title": "Stretch", "duration": 300000000000 },
    { "title": "Journal", "duration": 600000000000 }
  ]
}

POST /v1/routines/{id}/instantiate accepts an optional body:

{ "start_time": "2024-01-15T09:00:00Z" }

If omitted, steps start from the current time.


Endpoints

Bulk fetch

MethodPathDescriptionResponse
GET/v1/dataFetch all collections at onceAllData

AllData shape:

{
  "actions":          [ Action ],
  "events":           [ Event ],
  "routines":         [ Routine ],
  "action_templates": [ ActionTemplate ],
  "event_templates":  [ EventTemplate ]
}

Actions

MethodPathDescriptionResponse
GET/v1/actionsList all actions[Action]
POST/v1/actionsCreate (server assigns ID, must be backlogged)201 Action
GET/v1/actions/{id}Fetch by IDAction
PUT/v1/actions/{id}Upsert (client assigns ID)Action
DELETE/v1/actions/{id}Soft-delete204
POST/v1/actions/{id}/queueBacklogged → queued; refreshes pipeline[Action] changed
POST/v1/actions/{id}/backlogQueued → backloggedAction
POST/v1/actions/{id}/completeMark complete; spawns next recurrence if setCompleteResult
POST/v1/actions/{id}/saveSave as a reusable templateAction
POST/v1/actions/{id}/clear_durationRemove the explicit durationAction

CompleteResult:

{ "completed": Action, "next": Action | null }

Action templates

MethodPathDescriptionResponse
GET/v1/actions/templatesList all templates[ActionTemplate]
POST/v1/actions/templatesCreate template201 ActionTemplate
GET/v1/actions/templates/{id}Fetch by IDActionTemplate
PUT/v1/actions/templates/{id}Upsert templateActionTemplate
DELETE/v1/actions/templates/{id}Soft-delete204

Events

MethodPathDescriptionResponse
GET/v1/eventsList all events[Event]
POST/v1/eventsCreate event201 Event
GET/v1/events/{id}Fetch by IDEvent
PUT/v1/events/{id}Upsert eventEvent
DELETE/v1/events/{id}Soft-delete204
POST/v1/events/{id}/saveSave as a reusable templateEvent

Event templates

MethodPathDescriptionResponse
GET/v1/events/templatesList all templates[EventTemplate]
POST/v1/events/templatesCreate template201 EventTemplate
GET/v1/events/templates/{id}Fetch by IDEventTemplate
PUT/v1/events/templates/{id}Upsert templateEventTemplate
DELETE/v1/events/templates/{id}Soft-delete204

Routines

MethodPathDescriptionResponse
GET/v1/routinesList all routines[Routine]
POST/v1/routinesCreate routine (server assigns ID)201 Routine
GET/v1/routines/{id}Fetch by IDRoutine
PUT/v1/routines/{id}Upsert routineRoutine
DELETE/v1/routines/{id}Soft-delete204
POST/v1/routines/{id}/instantiateCreate static queued actions from steps[Action]

Pipeline

MethodPathDescriptionResponse
POST/v1/pipeline/refreshRequeue missed/floating actions[Action] changed
POST/v1/pipeline/expeditePull floating actions within 6h toward now[Action] changed

Live updates (SSE)

GET /v1/changes/stream

A persistent Server-Sent Events stream. Each event is a JSON object with a type field. The client should treat each event as a signal to re-fetch the relevant collection — events carry no payload.

data: {"type":"actions_changed"}

data: {"type":"pipeline_changed"}

data: {"type":"events_changed"}

data: {"type":"routines_changed"}

data: {"type":"action_templates_changed"}

data: {"type":"event_templates_changed"}

A keep-alive ping comment is sent every 15 seconds to prevent proxy timeouts.

Browser (JavaScript):

const es = new EventSource("https://api.subroutineapp.com/v1/changes/stream");
es.onmessage = (e) => {
  const { type } = JSON.parse(e.data);
  if (type === "actions_changed") fetchActions();
};

Nushell:

curl -sN https://api.subroutineapp.com/v1/changes/stream

Code examples

Nushell

let server = "https://api.subroutineapp.com"

# Fetch everything at once
http get $"($server)/v1/data" | select actions events routines

# Create a backlogged action
let id = (random uuid)
http post $"($server)/v1/actions" {
    id: $id
    lineage_id: $id
    routine_id: null
    template_id: null
    title: "Buy milk"
    content: null
    duration: null
    recurrence: null
    state: {type: backlogged, date: null}
}

# Queue it, complete it
http post $"($server)/v1/actions/($id)/queue" {}
http post $"($server)/v1/actions/($id)/complete" {}

# Expedite the pipeline
http post $"($server)/v1/pipeline/expedite" {}

curl

SERVER="https://api.subroutineapp.com"
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')

# Create an action
curl -X POST "$SERVER/v1/actions" \
  -H "Content-Type: application/json" \
  -d "{\"id\":\"$ID\",\"lineage_id\":\"$ID\",\"routine_id\":null,\"template_id\":null,\"title\":\"Daily standup\",\"content\":null,\"duration\":null,\"recurrence\":{\"unit\":\"days\",\"every\":1},\"state\":{\"type\":\"backlogged\",\"date\":null}}"

# List actions
curl "$SERVER/v1/actions" | jq '.[].title'

# SSE stream
curl -sN "$SERVER/v1/changes/stream"

Python

import httpx, uuid

server = "https://api.subroutineapp.com"

# Fetch all data
data = httpx.get(f"{server}/v1/data").json()
print([a["title"] for a in data["actions"]])

# Create and complete an action
action_id = str(uuid.uuid4())
httpx.post(f"{server}/v1/actions", json={
    "id": action_id, "lineage_id": action_id,
    "routine_id": None, "template_id": None,
    "title": "Write tests", "content": None,
    "duration": None, "recurrence": None,
    "state": {"type": "backlogged", "date": None},
})
httpx.post(f"{server}/v1/actions/{action_id}/queue")
httpx.post(f"{server}/v1/actions/{action_id}/complete")

# Subscribe to live changes
with httpx.stream("GET", f"{server}/v1/changes/stream") as r:
    for line in r.iter_lines():
        if line.startswith("data:"):
            print(line)

Self-hosting

The server is a single statically-linked binary for Linux x86-64, backed by PostgreSQL.

# Required environment variables
DATABASE_URL=postgresql://subroutine:<password>@127.0.0.1/subroutine
BIND_ADDR=0.0.0.0:3000

./simple-server  # runs migrations automatically on startup

See the GitHub repository for build and deployment instructions.