API Reference

Last updated: 2026-02-22

REST API for managing tasks, categories, and integrating with external tools like Obsidian.

Base URL: https://todone.fyi

Authentication

All API endpoints require authentication via API key (recommended for scripts and external tools) or session cookie (used by the web UI).

Creating an API Key

  1. Log in to the ToDone web app.
  2. Navigate to your account settings and create a new API key, or use the API directly (requires an active session):
curl -X POST https://<your-domain>/api/keys \
  -H "Content-Type: application/json" \
  -b "session-cookie-here" \
  -d '{"label": "Obsidian Sync"}'
  1. The response includes the raw key — save it immediately, it cannot be retrieved again:
{
  "key": "td_a1b2c3d4e5f6...",
  "label": "Obsidian Sync"
}

Using an API Key

Pass the key as a Bearer token in the Authorization header:

Authorization: Bearer td_a1b2c3d4e5f6...

Write Access

Endpoints that create, update, or delete resources require write access. Write access may be restricted based on the user's subscription status. If write access is denied, the API returns 403 Forbidden.


Field Validation Limits

All text fields enforce maximum length limits:

FieldMax Length
Task title500 chars
Task body50,000 chars
Category / stage name100 chars
API key label100 chars
Markdown import1,000,000 bytes (1 MB)

Importing Tasks from Obsidian (Markdown)

This is the primary endpoint for pushing tasks from an Obsidian vault.

POST /api/tasks/markdown

Accepts a block of markdown containing Obsidian Tasks-formatted checkboxes and creates corresponding tasks in ToDone. If an active task with the same title already exists, its metadata is updated; otherwise the title is reported as skipped.

Headers:

HeaderValue
AuthorizationBearer td_<your-api-key>
Content-Typeapplication/json

Query Parameters:

ParameterTypeRequiredDescription
categorystringNoName of an existing category to assign all tasks to

Request Body:

FieldTypeRequiredDescription
markdownstringYesRaw markdown text containing task checkboxes (max 1 MB)

Response (201):

{
  "created": [ /* array of newly created task objects */ ],
  "updated": [ /* array of tasks whose metadata was updated */ ],
  "skipped": [ "Task title with no changes", "..." ]
}

Stage Mapping

Imported tasks are assigned to stages based on their checkbox status:

CheckboxStage assigned
- [ ]First non-complete stage (e.g. TODO)
- [/]Second non-complete stage (e.g. DOING), or first if only one exists
- [x]First complete stage (e.g. DONE)

Upsert Behavior

When a task with the same title (case-insensitive) already exists as an active (non-completed) task:

  • If the imported task has different metadata (priority, dates, recurrence), the existing task is updated and included in the updated array.
  • If all metadata matches, the title is added to the skipped array.
  • Only completed tasks with matching titles do not block new task creation.

Supported Markdown Format

The parser understands Obsidian Tasks syntax — standard markdown checkboxes with emoji signifiers for metadata:

Basic tasks

- [ ] An open task
- [x] A completed task

Task with a body / description

Indented lines immediately after a checkbox become the task body:

- [ ] Plan the offsite
  Book venue, send invites, arrange catering
  Budget: $5,000

Priority emojis

EmojiPriority
🔺Urgent
High
🔼Medium
🔽Low
Lowest

Date signifiers

Each takes a YYYY-MM-DD date:

EmojiMeaning
📅Due date
Scheduled date
🛫Start date
Created date
Done date

Recurrence

EmojiUsage
🔁Followed by a pattern, e.g. every week

Supported recurrence patterns: every day, every N days, every week, every N weeks, every month, every N months, every year, every N years, every Monday (or any day), every weekday. Append when done to calculate the next date from the completion date instead of the original date.

Full example

- [ ] Buy groceries 🔺 📅 2026-03-07 ⏳ 2026-03-01 🛫 2026-02-15 🔁 every week
  Don't forget milk and eggs
- [ ] Write quarterly report ⏫ 📅 2026-03-31
- [x] File taxes 🔼 📅 2026-02-15 ✅ 2026-02-08
- [ ] Water the plants 🔽 🔁 every 3 days

Example: Push tasks from a file

# Push an entire markdown file
curl -X POST "https://<your-domain>/api/tasks/markdown" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d "{\"markdown\": $(jq -Rs . < ~/vault/tasks.md)}"

Example: Push tasks into a specific category

curl -X POST "https://<your-domain>/api/tasks/markdown?category=Work" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d "{\"markdown\": $(jq -Rs . < ~/vault/work-tasks.md)}"

Example: Inline markdown

curl -X POST "https://<your-domain>/api/tasks/markdown" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "markdown": "- [ ] Review PR #42 ⏫ 📅 2026-02-10\n- [ ] Deploy staging 🔼"
  }'

Obsidian Shell Script

Here is a ready-to-use shell script you can save in your vault or run via cron to sync tasks automatically:

#!/usr/bin/env bash
# sync-to-todone.sh — Push Obsidian tasks to ToDone
# Usage: ./sync-to-todone.sh [file-or-directory] [category]

set -euo pipefail

TODONE_URL="${TODONE_URL:-https://<your-domain>}"
TODONE_API_KEY="${TODONE_API_KEY:-td_<your-api-key>}"

TARGET="${1:-.}"
CATEGORY="${2:-}"

# Build category query param
CATEGORY_PARAM=""
if [[ -n "$CATEGORY" ]]; then
  CATEGORY_PARAM="?category=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$CATEGORY'))")"
fi

# Collect all markdown files
if [[ -d "$TARGET" ]]; then
  FILES=$(find "$TARGET" -name "*.md" -type f)
elif [[ -f "$TARGET" ]]; then
  FILES="$TARGET"
else
  echo "Error: $TARGET is not a valid file or directory" >&2
  exit 1
fi

TOTAL_CREATED=0
TOTAL_SKIPPED=0

for FILE in $FILES; do
  echo "Processing: $FILE"

  # Extract only lines that are task checkboxes (and their indented body lines)
  MARKDOWN=$(awk '
    /^- \[[ xX]\]/ { printing=1; print; next }
    printing && /^[[:space:]]+[^[:space:]]/ { print; next }
    { printing=0 }
  ' "$FILE")

  if [[ -z "$MARKDOWN" ]]; then
    echo "  No tasks found, skipping."
    continue
  fi

  PAYLOAD=$(jq -n --arg md "$MARKDOWN" '{markdown: $md}')

  RESPONSE=$(curl -s -X POST "${TODONE_URL}/api/tasks/markdown${CATEGORY_PARAM}" \
    -H "Authorization: Bearer ${TODONE_API_KEY}" \
    -H "Content-Type: application/json" \
    -d "$PAYLOAD")

  CREATED=$(echo "$RESPONSE" | jq '.created | length')
  SKIPPED=$(echo "$RESPONSE" | jq '.skipped | length')

  echo "  Created: $CREATED, Skipped (duplicates): $SKIPPED"
  TOTAL_CREATED=$((TOTAL_CREATED + CREATED))
  TOTAL_SKIPPED=$((TOTAL_SKIPPED + SKIPPED))
done

echo ""
echo "Done. Total created: $TOTAL_CREATED, Total skipped: $TOTAL_SKIPPED"

Usage:

# Set your credentials
export TODONE_URL="https://todone.example.com"
export TODONE_API_KEY="td_a1b2c3d4..."

# Sync a single file
./sync-to-todone.sh ~/vault/Projects/tasks.md

# Sync all markdown files in a directory, assigning to a category
./sync-to-todone.sh ~/vault/Work/ "Work"

# Sync your entire vault
./sync-to-todone.sh ~/vault/

Initialization

GET /api/init

Bulk endpoint for initial app load. Returns all data needed to render the dashboard in a single request.

Query Parameters:

ParameterTypeDefaultDescription
sortstringpositionSort tasks by: position, dueDate, priority, scheduledDate

Response (200):

{
  "tasks": [ /* array of task objects */ ],
  "categories": [ /* array of category objects */ ],
  "stages": [ /* array of stage objects */ ],
  "workspaces": [ /* array of workspace objects */ ],
  "activeWorkspaceId": "...",
  "isAdmin": false
}

Workspaces

GET /api/workspaces

List all workspaces for the authenticated user.

Response (200): Array of workspace objects.

POST /api/workspaces

Create a new workspace. Auto-creates default stages (TODO, DOING, DONE) and sets it as the active workspace.

Request Body:

FieldTypeRequiredDescription
namestringYesWorkspace name

Response (201): The created workspace object.

GET /api/workspaces/active

Returns the authenticated user's active workspace ID.

Response (200):

{ "activeWorkspaceId": "..." }

POST /api/workspaces/:id/switch

Set the specified workspace as the active workspace.

Response (200):

{ "ok": true, "workspaceId": "..." }

PATCH /api/workspaces/:id

Update a workspace (name, position).

DELETE /api/workspaces/:id

Delete a workspace. Cannot delete the last workspace. Returns the new active workspace ID.

Response (200):

{ "activeWorkspaceId": "..." }

POST /api/workspaces/:id/connect-google

Initiate Google OAuth flow for the workspace. Returns a URL to redirect the user to.

Response (200):

{ "url": "https://accounts.google.com/..." }

POST /api/workspaces/:id/disconnect-google

Disconnect Google Calendar from the workspace.

Response (200):

{ "ok": true }

Tasks

GET /api/tasks

List tasks for the authenticated user.

Query Parameters:

ParameterTypeDefaultDescription
stageIdstringFilter by stage ID
prioritystringFilter: LOWEST, LOW, MEDIUM, HIGH, URGENT
categoryIdstringFilter by category ID
sortstringpositionSort by: position, dueDate, priority, scheduledDate
orderstringascSort order: asc or desc

Response (200): Array of task objects (includes nested category and stage).

Note: List responses exclude body and updatedAt for performance. Use GET /api/tasks/:id for the full task object.

curl "https://<your-domain>/api/tasks?sort=dueDate&order=asc" \
  -H "Authorization: Bearer td_<your-api-key>"

POST /api/tasks

Create a single task (JSON, not markdown).

Request Body:

FieldTypeRequiredDefaultDescription
titlestringYesTask title (max 500 chars)
bodystringNonullDescription / notes (max 50,000 chars)
stageIdstringNoFirst non-complete stageStage ID
prioritystringNonullLOWEST, LOW, MEDIUM, HIGH, or URGENT
durationintegerNonullEstimated duration in minutes
dueDatestringNonullISO 8601 date
scheduledDatestringNonullISO 8601 date
startDatestringNonullISO 8601 date
recurrencestringNonulle.g. every week
categoryIdstringNonullID of an existing category
forcebooleanNofalseSkip fuzzy duplicate check

Duplicate Detection:

Before creating a task, the API checks for duplicates among active (non-completed) tasks:

  1. Exact match (case-insensitive title) — returns 409 with:

    { "error": "DUPLICATE", "message": "An active task with this title already exists", "existingId": "..." }
    
  2. Fuzzy match (Jaro-Winkler similarity >= 0.85) — returns 409 with:

    { "error": "SIMILAR", "message": "Similar active tasks found", "similarTasks": [{ "id": "...", "title": "..." }] }
    

Set force: true to skip the fuzzy similarity check. Exact duplicates are always rejected.

Response (201): The created task object.

curl -X POST "https://<your-domain>/api/tasks" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Review PR #42",
    "priority": "HIGH",
    "duration": 30,
    "dueDate": "2026-02-10"
  }'

PATCH /api/tasks/:id

Update a task. Send only the fields you want to change. All fields from POST /api/tasks are supported (except force), plus position for reordering.

Additional fields:

FieldTypeDescription
positionfloatPosition for task ordering
calendarEventIdstringClear or set the linked Google Calendar event ID

Completion behavior:

When moving a task to a stage with isComplete: true, completedAt is set automatically. Moving back to a non-complete stage clears completedAt.

Recurrence on completion:

If the completed task has a recurrence rule, a new task is created with shifted dates in the first non-complete stage. The response contains both tasks:

{
  "completed": { /* the completed task */ },
  "next": { /* the newly created recurring task */ }
}

Regular (non-recurring) updates return the updated task object directly.

curl -X PATCH "https://<your-domain>/api/tasks/clxyz123" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{"stageId": "<done-stage-id>"}'

DELETE /api/tasks/:id

Delete a task. Returns 204 No Content.

curl -X DELETE "https://<your-domain>/api/tasks/clxyz123" \
  -H "Authorization: Bearer td_<your-api-key>"

POST /api/tasks/batch

Create multiple tasks in a single request. Useful for reducing round-trips when creating several tasks at once (e.g. from a Claude skill or automation script).

Request Body:

FieldTypeRequiredDescription
tasksarrayYesArray of task objects (max 50 per request)

Each task object accepts the same fields as POST /api/tasks:

FieldTypeRequiredDefaultDescription
titlestringYesTask title (max 500 chars)
bodystringNonullDescription / notes (max 50,000 chars)
stageIdstringNoFirst non-complete stageStage ID
prioritystringNonullLOWEST, LOW, MEDIUM, HIGH, or URGENT
durationintegerNonullEstimated duration in minutes
dueDatestringNonullISO 8601 date
scheduledDatestringNonullISO 8601 date
startDatestringNonullISO 8601 date
recurrencestringNonulle.g. every week
categoryIdstringNonullID of an existing category
forcebooleanNofalseSkip fuzzy duplicate check

Duplicate Detection: Same rules as POST /api/tasks — exact duplicates are rejected per-task, fuzzy matches rejected unless force: true. Additionally, duplicate titles within the same batch are rejected.

Response (201): Partial success supported — valid tasks are created, invalid ones reported as errors.

{
  "created": [ { /* task object */ }, { /* task object */ } ],
  "errors": [ { "index": 2, "error": "Title is required" } ]
}

If all tasks fail validation, returns 400 with empty created array.

curl -X POST "https://<your-domain>/api/tasks/batch" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "tasks": [
      { "title": "Review PR #42", "priority": "HIGH", "force": true },
      { "title": "Deploy staging", "force": true },
      { "title": "Update docs", "categoryId": "cat-123", "force": true }
    ]
  }'

POST /api/tasks/:id/move

Move a task from the current workspace to a different workspace. Automatically maps stages and categories by name in the destination workspace. If no matching stage is found by name, a new stage is created in the destination workspace (copying name, color, and isComplete from the source). If no matching category is found by name, a new category is created (copying name and color from the source).

Request Body:

FieldTypeRequiredDescription
workspaceIdstringYesDestination workspace ID
unlinkCalendarbooleanConditionalRequired when the task has a calendarEventId. Set true to clear the calendar link, false to keep it.

Response (200): Updated task object with new workspace, stage, and category IDs.

Errors:

StatusCondition
400Missing workspaceId, same workspace, destination not owned by user, or missing unlinkCalendar when task has calendar event
404Task not found in current workspace
409Active task with same title exists in destination workspace
curl -X POST "https://<your-domain>/api/tasks/clxyz123/move" \
  -H "Authorization: Bearer td_<your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId": "dest_workspace_id"}'

GET /api/tasks/duplicates

Find duplicate and similar tasks across all of the authenticated user's tasks.

Response (200):

{
  "groups": [
    {
      "type": "exact",
      "normalizedTitle": "buy groceries",
      "tasks": [ /* tasks with identical titles */ ]
    },
    {
      "type": "similar",
      "normalizedTitle": "buy groceries",
      "tasks": [ /* tasks with Jaro-Winkler similarity >= 0.85 */ ]
    }
  ],
  "count": 2
}

Each task in a group includes: id, title, completedAt, stageId, stage, category, createdAt, updatedAt.

Response includes Cache-Control: private, no-cache.

curl "https://<your-domain>/api/tasks/duplicates" \
  -H "Authorization: Bearer td_<your-api-key>"

Insights

GET /api/insights

Returns productivity analytics for the active workspace. Metrics are computed over a configurable time window and include task completion trends, peak productivity hours, category breakdowns, avoided tasks, and on-time delivery rates.

Query Parameters:

ParameterTypeDefaultDescription
daysstring7Time window for retrospective metrics: 7, 14, or 30

Response (200):

{
  "completed": {
    "count": 23,
    "previous": 20,
    "daily": [2, 4, 3, 5, 3, 2, 4]
  },
  "peakHours": {
    "start": 9,
    "end": 11,
    "distribution": [0, 0, 0, 0, 0, 0, 1, 2, 4, 8, 9, 6, 3, 5, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0]
  },
  "categories": [
    { "id": "...", "name": "Work", "color": "#3b82f6", "count": 12 }
  ],
  "avoided": [
    { "id": "...", "title": "File taxes", "ageDays": 21, "pastDueDates": 1, "score": 9.2 }
  ],
  "onTime": {
    "onTime": 18,
    "late": 7,
    "previousOnTimeRate": 0.77
  }
}

Response fields:

FieldDescription
completed.countTasks completed in the current period
completed.previousTasks completed in the equivalent prior period (for comparison)
completed.dailyArray of daily completion counts (one entry per day in the window)
peakHours.startHour (0-23) when the most productive block begins
peakHours.endHour (0-23) when the most productive block ends
peakHours.distributionArray of 24 values representing completions per hour of the day
categoriesBreakdown of completed tasks by category
avoidedActive tasks ranked by avoidance score (age, missed due dates)
avoided[].ageDaysDays since the task was created
avoided[].pastDueDatesNumber of times the due date has passed without completion
avoided[].scoreComposite avoidance score (higher = more avoided)
onTime.onTimeTasks completed on or before their due date
onTime.lateTasks completed after their due date
onTime.previousOnTimeRateOn-time rate (0-1) from the prior period

Errors:

StatusCondition
400Invalid days value (must be 7, 14, or 30)
401Not authenticated
curl "https://<your-domain>/api/insights?days=14" \
  -H "Authorization: Bearer td_<your-api-key>"

Stages

Stages define the workflow columns in the kanban view. Each user has their own set of stages.

GET /api/stages

List all stages for the authenticated user, ordered by position. Auto-creates default stages (TODO, DOING, DONE) if none exist.

Response (200): Array of stage objects.

POST /api/stages

Create a stage.

Request Body:

FieldTypeRequiredDefaultDescription
namestringYesStage name (max 100 chars)
colorstringNonullHex or OKLCH color value
positionnumberNoautoPosition for ordering (auto-assigned if omitted)
isCompletebooleanNofalseWhether this stage means "done"

Returns 409 Conflict if a stage with the same name already exists in the workspace.

PATCH /api/stages/:id

Update a stage (name, color, position, isComplete).

DELETE /api/stages/:id

Delete a stage. Tasks in the deleted stage are moved to the first remaining stage (by position). Cannot delete the last stage. Returns 204 No Content.


Categories

GET /api/categories

List all categories for the authenticated user, ordered by position.

Response (200): Array of category objects. Supports ETag-based caching (304 Not Modified).

POST /api/categories

Create a category.

Request Body:

FieldTypeRequiredDescription
namestringYesCategory name (max 100 chars)
colorstringNoHex color code

Position is auto-assigned.

Returns 409 Conflict if a category with the same name already exists in the workspace.

PATCH /api/categories/:id

Update a category (name, color, position).

DELETE /api/categories/:id

Delete a category. Returns 204 No Content.


Google Calendar

GET /api/calendar/connect

Initiates Google OAuth flow. Redirects to Google's consent screen. Requires session auth.

GET /api/calendar/callback

OAuth callback. Stores tokens and redirects back to the app.

GET /api/calendar/status

Returns { connected: true/false } indicating whether a Google Calendar is connected.

POST /api/calendar/disconnect

Revokes tokens and disconnects Google Calendar. Clears calendarEventId from all tasks.

POST /api/calendar/block

Block time on Google Calendar for a task.

Request Body:

FieldTypeRequiredDescription
taskIdstringYesTask ID to block time for
datestringNoYYYY-MM-DD date to check (defaults to today)
slotStartstringNoISO datetime — if provided, creates the event
timeZonestringNoIANA timezone (defaults to UTC)

Uses the task's duration field, defaulting to 30 minutes if not set.

When slotStart is omitted, returns available time slots:

{
  "available": [ /* array of available time slot objects */ ],
  "duration": 30
}

When slotStart is provided, creates the calendar event and returns the updated task object with calendarEventId set.

PATCH /api/calendar/block/:taskId

Update a linked calendar event.

Request Body:

FieldTypeRequiredDescription
titlestringNoEvent title
descriptionstringNoEvent description
startTimestringNoISO datetime for new start time
durationMinutesintegerNoDuration in minutes (defaults to task duration or 30)
timeZonestringNoIANA timezone

Response: { "ok": true }

DELETE /api/calendar/block/:taskId

Remove the linked calendar event and clear calendarEventId from the task.

Response: { "ok": true }


API Keys

API key endpoints require session authentication (not API key auth). Manage keys through the web UI or an authenticated session.

GET /api/keys

List your API keys, ordered by most recently created. Returns id, label, createdAt — not the key itself.

POST /api/keys

Create a new API key. Label max length is 100 characters. The raw key is returned once in the response.

DELETE /api/keys/:id

Revoke an API key. Returns 204 No Content.


Subscription & Billing

These endpoints integrate with Stripe for subscription management.

POST /api/stripe/checkout

Create a Stripe Checkout session to start a subscription.

Authentication: Required (session or API key).

Response (200):

{ "url": "https://checkout.stripe.com/..." }

Redirect the user to the returned URL to complete payment.

POST /api/stripe/portal

Create a Stripe Customer Portal session to manage an existing subscription (update payment method, cancel, etc.).

Authentication: Required (session or API key). User must have an existing Stripe customer ID.

Response (200):

{ "url": "https://billing.stripe.com/..." }

POST /api/stripe/webhook

Handles incoming Stripe webhook events. Authenticated via Stripe signature verification (not API key or session).

Handled events:

EventAction
checkout.session.completedLinks Stripe customer ID to user
customer.subscription.updatedUpdates subscription status and plan
customer.subscription.deletedMarks subscription as canceled
invoice.payment_failedMarks subscription as past due

Admin Endpoints

These endpoints require session authentication with the ADMIN role. Returns 403 if the user is not an admin.

POST /api/admin/impersonate

Start impersonating another user.

Request Body:

FieldTypeRequiredDescription
userIdstringYesID of user to impersonate

Response (200): { "success": true, "userId": "..." }

POST /api/admin/stop-impersonate

Stop impersonating and return to the admin's own session.

Response (200): { "success": true }


Cron Jobs

POST /api/cron/prune-logs

Delete API log entries older than 90 days.

Authentication: Bearer token matching the CRON_SECRET environment variable.

curl -X POST "https://<your-domain>/api/cron/prune-logs" \
  -H "Authorization: Bearer <CRON_SECRET>"

Response (200):

{
  "success": true,
  "deleted": 142,
  "cutoffDate": "2025-11-16T00:00:00.000Z"
}

Error Responses

All errors follow this format:

{
  "error": "Description of what went wrong"
}
StatusMeaning
400Bad request (missing or invalid fields)
401Unauthorized (missing or invalid API key)
403Forbidden (insufficient permissions or write access denied)
404Resource not found or doesn't belong to you
409Conflict (duplicate or similar task exists)
500Internal server error