Tuhmo API
Read and write tasks, notes, clients and projects in Tuhmo from your own systems, and get a webhook when something changes.
Getting started
Create a key in Settings, then Developer, under API keys. The key is shown only once, so keep it somewhere safe.
Send the key in the Authorization header of every request, then call /me to check that it works.
curl https://app.tuhmo.com/api/v1/me \ -H "Authorization: Bearer tmk_..."
What a key can do
A key acts as the person who created it, in the workspace it was created in. It sees and changes only what that person can. Other people's private pages stay private. If the person leaves the workspace, the key stops working right away.
Keys are either read only or read and write. Free workspaces get 2 read only keys per person. Plus and above get 10 keys per person that can also write. Money documents are read only on every plan, and the API has no way to send documents to clients.
Endpoints
Every path starts with https://app.tuhmo.com/api/v1. {id} is the 24 character id of the item. Requests and responses are JSON.
| Method | Path | What it does |
|---|---|---|
| GET | /me | Who owns this key, which workspace, and whether it can write |
| GET | /tasks | List tasks |
| GET | /tasks/{id} | Get a task |
| POST | /tasks | Create a task (title and status are required) |
| PATCH | /tasks/{id} | Update some fields of a task |
| DELETE | /tasks/{id} | Move a task to the trash |
| PATCH | /tasks/{id}/props | Update custom properties |
| GET | /tasks/{id}/comments | Comments on a task |
| POST | /tasks/{id}/comments | Comment on a task |
| GET | /tasks/{id}/github | GitHub pull requests, issues and commits linked to a task |
| GET | /tasksets | Task sets |
| GET | /notes/tree | Tree of every note you can see |
| GET | /notes/search?q= | Search notes |
| GET | /notes/{id} | Get a note with its content |
| POST | /notes | Create a note |
| PATCH | /notes/{id} | Update a note's title or content |
| DELETE | /notes/{id} | Move a note to the trash |
| GET | /notes/{id}/comments | Comments on a note |
| POST | /notes/{id}/comments | Comment on a note |
| GET | /clients | List clients |
| GET | /clients/{id} | Get a client |
| POST | /clients | Create a client |
| PUT | /clients/{id} | Update a client |
| GET | /clients/{id}/jobs | A client's projects |
| GET | /projects | List projects |
| GET | /projects/job/{id} | Get a project |
| POST | /jobs | Create a project |
| PATCH | /jobs/{id} | Update a project |
| GET | /invoices · /invoices/{id} | Invoices (read only) |
| GET | /proposals · /proposals/{id} | Quotes (read only) |
| GET | /contracts · /contracts/{id} | Contracts (read only) |
Example: create a task
curl -X POST https://app.tuhmo.com/api/v1/tasks \
-H "Authorization: Bearer tmk_..." \
-H "Content-Type: application/json" \
-d '{"title": "Send files to the client", "status": "todo", "dueDate": "2026-10-20"}'status is one of todo, doing, review or done. dueDate is YYYY-MM-DD.
Every task has a number, its short ID in the workspace (42 in TUH-42). Put it in a commit or PR and the task links to GitHub by itself. Linked PRs are at /tasks/{id}/github.
Webhooks
Owners and admins add destinations in Settings, then Developers, and pick the events they want. Every event is a JSON POST to the destination URL.
Note content is never sent. Call /notes/{id} with an API key when you need it. Private pages and anything under them are never sent.
| event | Sent when | What data has |
|---|---|---|
| task.created | A task is created | The whole task |
| task.updated | A task changes, such as title, status, dates or assignee | The whole task |
| task.status_changed | A task's status changes (sent along with task.updated) | The task, plus previousStatus |
| task.deleted | A task is moved to the trash | id, title, number, url |
| note.created | A note page is created | id, title, icon, parentId, projectId, clientId, timestamps, url (no content) |
| note.updated | A note changes, sent once editing stops for 30 seconds | Same as note.created |
| note.deleted | A note is moved to the trash | id, title, url |
| comment.created | A new comment on a task, note or table row | The comment, plus the url of where it is |
| ping | You press Test in settings | No data |
{
"id": "6720c1f2a9e4b3d1c0a1b2c3",
"event": "task.status_changed",
"at": "2026-10-20T09:30:00Z",
"workspaceId": "66f0a8e2c4b1d2e3f4a5b6c7",
"data": {
"id": "6714e0b2a9e4b3d1c0a19f00",
"previousStatus": "doing",
"status": "review",
"number": 42,
"title": "...",
"dueDate": "2026-10-20T00:00:00Z",
"url": "https://app.tuhmo.com/tasks?task=6714e0b2a9e4b3d1c0a19f00"
}
}The top level id identifies the delivery and stays the same on retries, so you can use it to ignore duplicates. at is when the event happened. data.id is the id of the task, note or comment.
Signatures and retries
Every request carries X-Event (the event name), X-Delivery-Id (same as id in the body) and X-Signature-256: an HMAC SHA256 of the raw body, signed with the destination's whsec_ secret. Check it before trusting the data.
import crypto from "node:crypto";
// body = the raw request body (before JSON.parse), secret = the destination's whsec_ secret
function verify(body, header, secret) {
const sig = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(header ?? ""));
}Respond with a 2xx within 15 seconds. Otherwise we retry 5 more times over about 10 hours. After 20 failures in a row the destination is turned off and owners and admins are notified.
Free workspaces get 1 destination and 100 deliveries per hour. Plus and above get 10 destinations and 1,000 deliveries per hour each.
Incoming webhooks
Let other systems send data into Tuhmo through a single URL, with no key. Good for GitHub Actions, Zapier or forms. Create a URL in Settings, Developers, under Incoming webhooks (owners and admins, Plus and above).
The URL acts as the person who created it and can see and change only what they can. It is shown once, and anyone with it can send data in. If it leaks, create a new URL and the old one stops working right away.
Send a JSON POST with one of three actions. Incoming webhooks never touch money documents and cannot send anything to clients.
| action | Fields | What it does |
|---|---|---|
| create_task | title note status dueDate projectId clientId | Creates a task. status defaults to todo |
| append_to_note | noteId text | Appends text to the end of a note |
| add_comment | taskId or noteId, and text | Comments on a task or note |
curl -X POST https://app.tuhmo.com/api/hooks/in/tih_... \
-H "Content-Type: application/json" \
-d '{"action": "create_task", "title": "Build failed on main", "dueDate": "2026-10-20"}'Success returns 201 or 200 with the id of what was created. A turned off URL returns 403 with code disabled. Up to 60 requests per minute per URL, with bodies up to 256 KB.
Every request is kept for 14 days in the URL's history: what it did, whether it worked, and which IP it came from.
Limits
On average 3 requests per second per key, with bursts up to 10, and no more than 10 requests per second per workspace. Over that you get a 429 with a Retry-After header in seconds.
Request bodies are limited to 1 MB.
Errors
Errors are JSON with a human readable error and a code your program can branch on.
| Status | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, wrong, expired or revoked key |
| 403 | read_only | A read only key tried to change data |
| 403 | plan | The workspace plan does not include this, such as writing on Free |
| 403 | The key's owner is not allowed to do this in the workspace | |
| 403 | disabled | The incoming webhook URL is turned off |
| 404 | not_found | The path is not part of the API, or the item does not exist |
| 413 | too_large | Request body over 1 MB (256 KB for incoming webhooks) |
| 429 | rate_limited | Too many requests, wait for Retry-After |

