Dunly API

Connect a Dunly workspace to your own systems — read and write tasks (and their attachment metadata) over HTTP, with delta sync, cursor pagination, and deletion tombstones.

Authentication

Every request needs an API integration key, sent as a bearer token:

Authorization: Bearer dnk_your_key_here

You generate a key in Settings → API integration keys inside the app. A key is shown once — store it somewhere safe. Revoke a key any time from the same screen; a revoked key stops working immediately.

A key reaches exactly one board — its scope says whose. Dunly tasks are private per person — no one, not even an admin, can read another person's tasks. A key generated in Settings is a personal key: it acts as the person who created it, with full read/write on that one person's own board — so treat it like a password. A key with the workspace scope (the default when a key is created without asking for personal, and the scope of every key made before personal keys existed) is instead its own principal, with its own isolated task list — everything it creates and reads belongs to the key, touching no person's board. Use that for machine-owned lists (automation, imports, bots). Under either scope, a key can never see any other person's private tasks.
A key cannot manage other keys, configure webhooks, or open support conversations — those are signed-in-person actions on the web. A key also only speaks the versioned /api/v1 surface below; the unversioned app routes answer 401 to a key.

Base URL & conventions

All endpoints live under your workspace:

https://YOUR_HANDLE.rooche.biz/dunly/api/v1/orgs/YOUR_HANDLE
{
  "data": [ …records… ],
  "page": { "limit": 100, "hasMore": true, "nextCursor": "eyJtIjoi…" }
}

Tasks

A task is title, description, tags (an array of strings), and done, plus the optional dueDate and priority (null when unset). Each record also carries updatedAt (touched on every create, update, and delete — the value you page a delta feed by) and deletedAt (null for a live task; an ISO-8601 timestamp for a tombstone).

GET/api/v1/orgs/YOUR_HANDLE/tasks

List tasks with cursor pagination, delta sync, and deletion tombstones.

QueryTypeNotes
limitintegerPage size. Default 100, max 500.
cursorstringThe nextCursor from the previous page. Keyset-based (stable under inserts) — page until hasMore is false.
updatedSincestringISO-8601. Returns only records whose updatedAt is strictly after this, oldest-change first — a delta feed. In this mode tombstones are always included (a delete is a change). Store the newest updatedAt you saw and pass it back next time.
includeDeletedbooleanWhen not doing a delta, set true to include soft-deleted records (each has a non-null deletedAt).
QueryTypeNotes
donebooleanFilter to done (true) or open (false) tasks.
expandstringexpand=attachments inlines each task's live attachment metadata so you don't fetch them per task.
Reconciling deletes. A delta pull repairs anything you missed: a removed task comes back with a non-null deletedAt in the updatedSince feed. Apply it as a delete on your side.

Example — walk a delta feed

# first sync: no watermark yet, page through everything
curl "https://YOUR_HANDLE.rooche.biz/dunly/api/v1/orgs/YOUR_HANDLE/tasks?limit=200" \
  -H "Authorization: Bearer dnk_your_key_here"
# → { "data": [...], "page": { "hasMore": true, "nextCursor": "…" } }
# follow nextCursor until hasMore is false, remembering the max updatedAt you saw.

# later: fetch only what changed (incl. deletes) since that watermark
curl "https://YOUR_HANDLE.rooche.biz/dunly/api/v1/orgs/YOUR_HANDLE/tasks?updatedSince=2026-08-06T09:15:04.000Z" \
  -H "Authorization: Bearer dnk_your_key_here"
GET/api/v1/orgs/YOUR_HANDLE/tasks/:id

Fetch one task: { "data": task }. A soft-deleted task answers 404 unless ?includeDeleted=true. Supports ?expand=attachments.

POST/api/v1/orgs/YOUR_HANDLE/tasks

Create a task. Returns 201 with { "data": task }.

FieldTypeNotes
titlestringRequired, ≤ 200 chars.
descriptionstring?Optional, ≤ 4000 chars.
tagsstring[]?Optional, ≤ 20 tags of ≤ 40 chars.
doneboolean?Optional; defaults to false.
dueDatestring?Optional ISO-8601 timestamp; an unparseable value is stored as null.
prioritystring?Optional low | medium | high; anything else is stored as null.

Idempotency

Send an Idempotency-Key header (any unique string, ≤ 200 chars) so a retried create after a dropped response never makes a duplicate — the first response is stored per workspace and replayed verbatim for the same key.

curl -X POST https://YOUR_HANDLE.rooche.biz/dunly/api/v1/orgs/YOUR_HANDLE/tasks \
  -H "Authorization: Bearer dnk_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f9c1a7e-import-4821" \
  -d '{ "title": "Follow up with supplier", "tags": ["work"] }'
PUT/api/v1/orgs/YOUR_HANDLE/tasks/:id

Update a task (same body as create). done may ride along; omitting it leaves the current value (or use the dedicated POST …/done below to flip it without re-sending the body). priority and dueDate follow the same rule: omit the field to leave it unchanged, or send it as null to clear it — so a partial edit never silently wipes a task’s due date or priority. (title, description, and tags are replaced from the body as sent.) Returns { "data": task }.

POST/api/v1/orgs/YOUR_HANDLE/tasks/:id/done

Set or toggle done without re-sending the whole body (PUT is a full replace and needs a title). Send { "done": true } / { "done": false } to set it explicitly, or an empty body to toggle. Becoming done starts the auto-archive clock; un-doing clears done, doneAt, and archivedAt. Returns { "data": task }.

POST/api/v1/orgs/YOUR_HANDLE/tasks/:id/archive

Archive a task — file it out of the active list into the archive. Distinct from delete: the task stays owned and restorable (a tombstone is gone). Idempotent — re-archiving keeps the original archivedAt. Returns { "data": task } with archivedAt set.

POST/api/v1/orgs/YOUR_HANDLE/tasks/:id/unarchive

Restore an archived task to the active list (clears archivedAt; leaves done as-is). Idempotent. Returns { "data": task }.

GET/api/v1/orgs/YOUR_HANDLE/tasks/archived

List archived tasks — { "data": [task, …] }, newest-archived first. Unpaged (an owner’s archive is bounded). The active GET …/tasks list excludes archived tasks; this is where they live.

DELETE/api/v1/orgs/YOUR_HANDLE/tasks/:id

Soft-delete a task. Returns { "data": task } with the task as a tombstone (deletedAt set). Repeating the delete answers the same tombstone.

Attachments metadata only

A task can carry media — voice, video, image, or file. The versioned API exposes the metadata and lifecycle of those attachments: what exists, its sniffed mimeType, sizeBytes, durationMs, the delta/tombstone feed to reconcile against, and soft-delete.

The bytes are not on this surface. Uploading and downloading the file itself happens on the first-party app routes only — a streaming multipart upload that sniffs the real bytes (never trusting a filename) and enforces the workspace storage quota, and a Range-supporting authed download. v1 deliberately does not duplicate (or relax) that path, and an integration key cannot call it: in v1 an attachment's binary is created and fetched by people in the Dunly apps, while your integration tracks and manages the records.
GET/api/v1/orgs/YOUR_HANDLE/tasks/:taskId/attachments

One task's attachment metadata (the task must be yours; a deleted task's list is still readable for reconciliation). Same pagination/delta parameters as the tasks list.

GET/api/v1/orgs/YOUR_HANDLE/attachments

Every attachment across your tasks — the delta/tombstone feed (?updatedSince, ?includeDeleted, ?cursor). ?taskId narrows to one task.

{
  "data": [
    {
      "id": "att_…", "taskId": "task_…", "kind": "voice",
      "mimeType": "audio/webm", "filename": "voice", "sizeBytes": 48213,
      "durationMs": 4120, "createdAt": "2026-08-06T09:15:04.000Z",
      "updatedAt": "2026-08-06T09:15:04.000Z", "deletedAt": null
    }
  ],
  "page": { "limit": 100, "hasMore": false, "nextCursor": null }
}
GET/api/v1/orgs/YOUR_HANDLE/attachments/:id

One attachment’s metadata: { "data": attachment }. Never the bytes. A tombstone answers 404 unless ?includeDeleted=true.

DELETE/api/v1/orgs/YOUR_HANDLE/attachments/:id

Soft-delete an attachment. The record tombstones (so a sync can reconcile it) but the file itself is removed immediately — deletion reclaims storage quota. Returns { "data": attachment } as a tombstone.

Errors

Errors are JSON: { "error": "message" }, with a matching status:

StatusMeaning
400Bad request — a validation rule failed (the message says which).
401Missing or invalid key (or a key on a non-/api/v1 route).
403Not allowed for a key (key management, webhooks, support) — or the workspace is suspended (code: "suspended").
404No such workspace or record. Another principal's task or attachment is indistinguishable from a missing one — that is the privacy model, not an omission.
429Rate limit exceeded — slow down and retry after Retry-After seconds.

Limits

← Back to Dunly