Public API
Read and write tracked time from a script — no Power-Up, no browser, no Trello session.
Base URL
https://ttp-api.aldatalabs.com/v1/public
Field-level schemas are generated from the code and served at /docs; the
OpenAPI document is at /openapi.json. This page covers what a schema cannot
tell you.
Real shapes, invented values
Every payload and every Python snippet below was executed against a running server, so
field names, types, statuses and entitlement masking are exactly what you will get. The
contents are a seeded demo — "Demo Workspace", Ada Lovelace, card
6a820d03…. No real board, card or person appears here, deliberately.
Quickstart
1. Ask for a key
Keys are issued by us — the Time Tracker Pro team — rather than from a screen inside the product. Email [email protected], or write to us from inside the Power-Up, and say three things:
- What it is for — the name goes on the key, so it can be identified later.
- Which boards it needs — all of them, or a named few.
- Whether it must write, or only read.
You get back one line, like
ttp_8fktxx2z_EpkNCG-iUk_l8p3wJdUvl35djibEX7tNcLnvOvYUsEg.
Treat that line as a password, not a setting
- It is shown once. Only a hash is stored, so nobody — including us — can read it back to you. Lose it and the fix is a rotate: the key keeps its name, its scope and its usage history, and only the secret changes.
- Anyone holding it is the key. There is no person behind it and no second factor: whoever has the string can do everything its scope allows, from anywhere.
- Keep it out of source control, front-end code and URLs. An environment variable or your secret manager — never a committed config file, never a query string (those end up in server logs and browser history), and never anything a browser downloads.
- If it leaks, ask for a revoke. It stops working on the next request — there is no cache to wait out — and a fresh key can be issued straight away.
2. Make a call
Every key can list the boards it reaches. That is the honest inventory of its scope, and
where the platform_board_id values everything else takes come from.
import os
import httpx
client = httpx.Client(
base_url="https://ttp-api.aldatalabs.com/v1/public",
headers={"Authorization": f"Bearer {os.environ['TTP_KEY']}"},
timeout=30,
)
boards = client.get("/boards").raise_for_status().json()
for board in boards["items"]:
print(board["platform_board_id"], board["name"])
export TTP_KEY=ttp_8fktxx2z_...
curl -H "Authorization: Bearer $TTP_KEY" \
https://ttp-api.aldatalabs.com/v1/public/boards
const BASE = "https://ttp-api.aldatalabs.com/v1/public";
const response = await fetch(`${BASE}/boards`, {
headers: { Authorization: `Bearer ${process.env.TTP_KEY}` },
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const boards = await response.json();
for (const board of boards.items) {
console.log(board.platform_board_id, board.name);
}
65a1b2c3d4e5f60718293a4b Demo Delivery 65a1b2c3d4e5f60718293a99 Demo Ops
Using requests instead?
The calls are identical — swap the import. httpx is used here because it
is what these examples were tested with.
3. Read the concepts you will hit immediately
Identifiers (why every id carries a platform),
Pagination (cursors, not page numbers) and
Errors (what 404 really means here). Ten minutes,
and they explain most surprises.
Authentication
Send the key as a bearer token. The ttp_ prefix is what separates a machine key
from the Power-Up's own short-lived session token — they share the header, and neither half
of the API accepts the other's credential.
Authorization: Bearer ttp_<prefix>_<secret>
One answer for every rejected credential Missing, malformed, unknown, revoked and expired all return the same 401 with the same body. Telling them apart would let anyone probe which keys exist.
{
"detail": "missing or invalid API key"
}
Access
Two independent things decide whether a call succeeds: the features enabled on the account, and the key's own write access.
| What | Needs |
|---|---|
| Using this API at all |
The api feature.
Otherwise 403 feature_required.
|
| Reports | Additionally the reports feature. |
| Card description and AI fields | Additionally ai. Reading without it returns null; writing returns 403. |
| Any write | A key created with write access. Otherwise 403 read_only_key. |
The api feature covers the credential, not the data — which is why a route still checks
whatever feature its in-product equivalent checks.
Ask for the narrowest key that does the job A key is read-only unless write access was requested, can be pinned to specific boards instead of the whole account, and can be given an expiry — worth doing for anything temporary, like a migration or a one-off backfill. None of it can be widened later without issuing a new key, which is the point.
Identifiers
Every id here is the platform's own — the ids you already have from Trello. Nothing you store from this API breaks when our database changes underneath.
- platform_board_id
- Trello board id.
- platform_item_id
- Trello card id.
- platform_user_id
- Trello member id.
- platform_list_id
- Trello list id.
Each travels with its platform, because uniqueness is on the pair:
one account may hold workspaces on more than one platform, and a bare native id would be
ambiguous the day it does. Add ?platform=trello to disambiguate; an id
matching on two platforms is 409 ambiguous_platform, never a silent pick.
One exception, and it is a real one
A time entry's id is an internal integer. An
entry exists only in the tracker and has no platform id at all, and
PATCH and DELETE need a handle for it.
Scope
A key reaches the boards under its account, optionally narrowed to a named few. That scope is recalculated on every request, so a board moved to another account leaves the key by itself — nobody has to remember to edit anything.
Anything outside the scope answers 404, never 403: you must not be able to learn that an id exists on a board you cannot see. "No such card" and "not your card" are the same sentence.
GET /boards is the definitive list of what a key can
reach.
Pagination
Every listing takes ?cursor=&limit= and returns
{"items": […], "next_cursor": N}, ordered by ascending row id.
next_cursor is null on the last page. limit defaults
to 100 and caps at 500.
Cursors rather than page numbers, so an incremental poll is possible at all: an offset
shifts under concurrent writes and would skip rows. An empty cursor= means the
first page, so passing the None you are holding is safe whichever HTTP client
you use.
def every_page(path, **params):
"""Yields every row, following the cursor."""
cursor = None
while True:
page = client.get(path, params={**params, "cursor": cursor, "limit": 100})
page.raise_for_status()
body = page.json()
yield from body["items"]
cursor = body["next_cursor"]
if cursor is None:
return
for card in every_page("/items", board_id="65a1b2c3d4e5f60718293a4b"):
print(card["platform_item_id"], card["name"])
curl -H "Authorization: Bearer $TTP_KEY" \
"$BASE/items?limit=100"
# -> "next_cursor": 417
curl -H "Authorization: Bearer $TTP_KEY" \
"$BASE/items?limit=100&cursor=417"
# -> "next_cursor": null (done)
async function* everyPage(path, params = {}) {
let cursor = null;
for (;;) {
const query = new URLSearchParams({ ...params, limit: "100" });
if (cursor !== null) query.set("cursor", String(cursor));
const response = await fetch(`${BASE}${path}?${query}`, { headers });
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const body = await response.json();
yield* body.items;
if (body.next_cursor === null) return;
cursor = body.next_cursor;
}
}
Selecting fields
Rows here are self-describing: every time entry names its card, board, member and platform. That is what lets you merge pages from different filters into one table without carrying the request's context around. It also means a request already narrowed to one card repeats that card's ids on every row.
Add ?fields= to get only the columns you use. Comma-separated, repeatable, or
both.
entries = client.get(
"/time-entries",
params={
"item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
"fields": "id,started_at,duration_seconds",
},
).raise_for_status().json()
print(entries["items"])
curl -H "Authorization: Bearer $TTP_KEY" \
"$BASE/time-entries?item_id=6b0c1d2e3f4a5b6c7d8e9f01&fields=id,started_at,duration_seconds"
const query = new URLSearchParams({
item_id: "6b0c1d2e3f4a5b6c7d8e9f01",
fields: "id,started_at,duration_seconds",
});
const entries = await (await fetch(`${BASE}/time-entries?${query}`, { headers })).json();
console.log(entries.items);
{
"items": [
{ "id": 1, "started_at": "2026-08-10T09:00:00Z", "duration_seconds": 7200 },
{ "id": 2, "started_at": "2026-08-11T09:00:00Z", "duration_seconds": 5400 },
{ "id": 3, "started_at": "2026-08-11T09:00:00Z", "duration_seconds": 3600 }
],
"next_cursor": null
}
The envelope keeps its shape — items and next_cursor are how
paging works, so trimming those would break the loop rather than the payload.
What it saves
Measured on this endpoint, 25 entries on one card, with and without four named fields:
| Request | Raw | Gzipped |
|---|---|---|
| Whole object | 6 746 B | 296 B |
With fields= | 2 071 B | 190 B |
| Saving | −69% | −36% |
Responses are compressed already
Most of the repetition is free on the wire — gzip is good at exactly this. Reach for
fields= to make responses easier to read, log and diff, or when parsing
millions of rows. Not as a bandwidth fix.
It works on every read endpoint, single objects included, and it can never widen a
response: fields are picked from an object already masked by your account's features, so asking for
ai_result_formatted without ai returns the same
null it always would.
A misspelt field is an error, not a silent omission
fields=duration_secondz returns
422 unknown_fields listing what it did not
recognise and every name available. Dropping the column quietly would show up as
missing data much later, somewhere else.
Dates and durations
Every timestamp in and out is UTC ISO 8601 with an offset. A timestamp
without one is rejected rather than guessed at — reports bucket by local day, so an
offsetless instant can land on the wrong day. Send +02:00 if that is what you
have; it is converted, and a retry in a different offset still counts as the same request.
Every duration is whole seconds. There is no value-and-unit pair anywhere:
duration_seconds, estimate_seconds, logged_seconds.
from and to bound started_at and are half-open —
to is exclusive — so consecutive periods never double-count an entry.
{
"detail": [
{
"type": "value_error",
"loc": ["body", "started_at"],
"msg": "Value error, timestamp must carry a UTC offset (e.g. 2026-08-16T09:00:00Z)",
"input": "2026-08-12T08:00:00"
}
]
}
Errors
| Status | Code | What happened |
|---|---|---|
| 401 | — | Key missing, malformed, unknown, revoked or expired — deliberately one answer. |
| 403 | feature_required | The account does not have the api, reports or ai feature. Write to [email protected] to have it enabled. |
| 403 | read_only_key | The key was issued without write access. |
| 404 | — | No such thing, or it exists outside this key's scope. |
| 409 | ambiguous_platform | That native id exists on two platforms. Add ?platform=. |
| 409 | external_id_conflict | That external id already names a different entry. |
| 409 | item_elsewhere | POST /items named a card another board already holds. |
| 409 | report_changed | Something in the report was deleted while it was being built. Retry. |
| 422 | unknown_fields | fields= named something not on the object. |
| 422 | — | The payload is outside the documented bounds. |
| 429 | rate_limited | Out of budget for this minute. See Retry-After. |
Handling them
def call(path, **params):
"""Turns the documented codes into something a script can branch on."""
response = client.get(path, params=params)
if response.status_code == 429:
raise RuntimeError(f"rate limited; retry after {response.headers['Retry-After']}s")
if response.status_code == 403:
raise PermissionError(response.json()["detail"].get("code", "forbidden"))
if response.status_code == 404:
return None # absent, or not in this key's scope
response.raise_for_status()
return response.json()
async function call(path, params = {}) {
const query = new URLSearchParams(params);
const response = await fetch(`${BASE}${path}?${query}`, { headers });
if (response.status === 429) {
throw new Error(`rate limited; retry after ${response.headers.get("Retry-After")}s`);
}
if (response.status === 403) {
const { detail } = await response.json();
throw new Error(detail.code ?? "forbidden");
}
if (response.status === 404) return null; // absent, or not in this key's scope
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
Rate limits
120 requests a minute per key. Every response to an authenticated call carries the budget,
the 429 included.
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 107 X-RateLimit-Reset: 1786964220 # Unix seconds — when the window reopens
A 401 carries no budget headers at all: there is no key, so there is no budget to report.
Boards
The boards this key reaches, and the entry point for everything else.
{
"items": [
{
"platform": "trello",
"platform_board_id": "65a1b2c3d4e5f60718293a4b",
"name": "Demo Delivery",
"platform_workspace_id": "5f2b1c9a8d7e6f0011223344",
"workspace_name": "Demo Workspace"
},
{
"platform": "trello",
"platform_board_id": "65a1b2c3d4e5f60718293a99",
"name": "Demo Ops",
"platform_workspace_id": "61aa2b3c4d5e6f0099887766",
"workspace_name": "Demo Workspace (Ops)"
}
],
"next_cursor": null
}
Two boards in two different Trello Workspaces, both billed to one account — which is why
platform_workspace_id is on every row rather than assumed.
| Query | Notes |
|---|---|
platform | Narrow to one platform. |
cursor, limit | Pagination. |
fields | Selecting fields. |
Lists
A board's columns. Reported per board, deliberately: a list moved between boards leaves a row on each, and this answers about the board you named.
{
"items": [
{ "platform": "trello", "platform_list_id": "65a1b2c3d4e5f6071829d001", "name": "Doing" },
{ "platform": "trello", "platform_list_id": "65a1b2c3d4e5f6071829d002", "name": "Done" }
],
"next_cursor": null
}
platform_list_id is what GET /items takes as
its list_id filter.
Members
A board's roster — and the only source of a platform_user_id that a
write may name. Time can be logged for a member of that board, and nobody else.
{
"items": [
{
"platform": "trello",
"platform_user_id": "5219e4d1b1a5b8f5a2000123",
"display_name": "Ada Lovelace",
"avatar_url": "https://trello-members.s3.amazonaws.com/5219e4d1b1a5b8f5a2000123/abc"
},
{
"platform": "trello",
"platform_user_id": "5219e4d1b1a5b8f5a2000456",
"display_name": "Grace Hopper",
"avatar_url": null
}
],
"next_cursor": null
}
The roster only ever grows: a member who leaves the board keeps their time entries and their name in old reports, so they stay listed.
Cards
Trello cards, with everything the tracker knows about them: estimate, logged time, Result
and — when the account has the ai feature — the description and AI fields.
A card is never created by accident
Every other call takes a card as given: an unknown
platform_item_id is a 404, and no write
creates one on the way past. To put a card in the tracker, say so —
POST /items, naming the board it belongs to.
Card ids are unique across all of Trello, so a card another board already holds is a 409 rather than a move. Moving a card between boards is the Power-Up's job, where Trello signs both ids.
Register a card
For a card your script has just created on the platform: it exists in Trello, and the tracker has no way to know that until somebody opens the board. Call this and time can land on it immediately.
| Field | Notes |
|---|---|
platform_board_id | Required. Must be a board this key reaches, or 404. |
platform_item_id | Required. The card's own id, as the platform returned it. |
name | Required. Stored exactly as sent. |
platform_list_id, list_name | Optional, both or neither. Creates or renames the board's list. |
platform | Only when the same id exists on two platforms. |
def register_card(board, card, name, list_id=None, list_name=None):
"""Idempotent: call it before every write, it will not duplicate."""
response = client.post(
"/items",
json={
"platform_board_id": board,
"platform_item_id": card,
"name": name,
"platform_list_id": list_id, # both or neither
"list_name": list_name,
},
)
if response.status_code == 409:
raise RuntimeError(f"{card} belongs to another board")
response.raise_for_status()
return response.json(), response.status_code == 201
card, created = register_card(board_id, "6b0c1d2e3f4a5b6c7d8e9f77", "Nightly rebuild")
print(card["name"], "created" if created else "already known")
curl -X POST \
-H "Authorization: Bearer $TTP_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform_board_id": "65a1b2c3d4e5f60718293a4b",
"platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f77",
"name": "GR: Formats: nightly rebuild",
"platform_list_id": "65a1b2c3d4e5f6071829d001",
"list_name": "Doing"
}' \
"$BASE/items"
async function registerCard(board, card, name, listId = null, listName = null) {
const response = await fetch(`${BASE}/items`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
platform_board_id: board,
platform_item_id: card,
name,
platform_list_id: listId, // both or neither
list_name: listName,
}),
});
if (response.status === 409) throw new Error(`${card} belongs to another board`);
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return { card: await response.json(), created: response.status === 201 };
}
The only 201 on this API
A repeat answers 200 with the same body, so the
status is the one signal that says whether this call is what put the card here.
POST /time-entries answers 200 even
on a create — do not copy that habit here.
Only name and the list are touched. Labels, the description, the
estimate and Result keep whatever they already had, and the card is never created on
Trello — that is your script's own step, before this one.
List cards
| Query | Notes |
|---|---|
board_id | A platform_board_id. |
list_id | A platform_list_id. |
label | Repeatable; matches any of them. |
platform | Narrow to one platform. |
cursor, limit, fields | See Pagination and Selecting fields. |
Get one card
Six fields depend on the ai feature. The same card, the same key, without it and with it:
{
"platform": "trello",
"platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
"platform_board_id": "65a1b2c3d4e5f60718293a4b",
"name": "Nightly ETL refresh",
"platform_list_id": "65a1b2c3d4e5f6071829d001",
"labels": ["backend", "data"],
"estimate_seconds": 36000,
"logged_seconds": 16200,
"result": "Rewrote the incremental load; nightly job is stable.",
"description": null,
"ai_result_formatted": null,
"ai_result_recommended_hours": null,
"ai_result_verification": null,
"ai_result_formatted_at": null,
"ai_result_verified_at": null
}
{
"platform": "trello",
"platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
"platform_board_id": "65a1b2c3d4e5f60718293a4b",
"name": "Nightly ETL refresh",
"platform_list_id": "65a1b2c3d4e5f6071829d001",
"labels": ["backend", "data"],
"estimate_seconds": 36000,
"logged_seconds": 16200,
"result": "Rewrote the incremental load; nightly job is stable.",
"description": "Nightly job drops rows when the source is late.",
"ai_result_formatted": "Rebuilt the nightly ETL.",
"ai_result_recommended_hours": "8.50",
"ai_result_verification": { "verified": true, "confidence": 0.92 },
"ai_result_formatted_at": "2026-08-17T10:56:25.501749Z",
"ai_result_verified_at": "2026-08-17T10:56:25.501749Z"
}
The keys never disappear, they go null
A field you are not entitled to is still in the response, set to null, so
your parser never branches on the shape of the payload.
The cost is that null means both "not set" and "not enabled on this
account". Ask us which features the account has to tell those apart — the payload
cannot say.
Set the estimate
card = client.put(
f"/items/{card_id}/estimate",
json={"estimate_seconds": 36000}, # None clears it
).raise_for_status().json()
print(card["estimate_seconds"]) # 36000
curl -X PUT \
-H "Authorization: Bearer $TTP_KEY" \
-H "Content-Type: application/json" \
-d '{"estimate_seconds": 36000}' \
"$BASE/items/6b0c1d2e3f4a5b6c7d8e9f01/estimate"
const response = await fetch(`${BASE}/items/${cardId}/estimate`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ estimate_seconds: 36000 }), // null clears it
});
const card = await response.json();
Write the Result
Result is the tracker's own summary of what was done — a different field from Trello's card description, which this API can read but never writes.
{
"result": "Rewrote the incremental load; nightly job is stable."
}
Write the AI fields
card = client.put(
f"/items/{card_id}/enrichment",
json={
"ai_result_formatted": "Rebuilt the nightly ETL.",
"ai_result_recommended_hours": "8.50", # decimal as a string
"ai_result_verification": {"verified": True, "confidence": 0.92},
},
).raise_for_status().json()
print(card["ai_result_formatted_at"]) # stamped by the server
curl -X PUT \
-H "Authorization: Bearer $TTP_KEY" \
-H "Content-Type: application/json" \
-d '{
"ai_result_formatted": "Rebuilt the nightly ETL.",
"ai_result_recommended_hours": "8.50",
"ai_result_verification": {"verified": true, "confidence": 0.92}
}' \
"$BASE/items/6b0c1d2e3f4a5b6c7d8e9f01/enrichment"
const response = await fetch(`${BASE}/items/${cardId}/enrichment`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
ai_result_formatted: "Rebuilt the nightly ETL.",
ai_result_recommended_hours: "8.50", // decimal as a string
ai_result_verification: { verified: true, confidence: 0.92 },
}),
});
const card = await response.json();
The two timestamps are ours, not yours
ai_result_formatted_at and ai_result_verified_at are stamped
by the server and cannot be sent. They are what the retention clock reads when the
ai feature ends, so a caller able to write them could move its own deletion deadline. Clearing a
value with null clears its timestamp too.
ai_result_recommended_hours is a decimal and travels as a
string — "8.50", not 8.5 — so no float
rounding happens in transit.
Omitted is not null, on every write
An omitted field is left alone; an explicit null
clears it. A body naming no fields at all is a 422 — on a one-field
endpoint, "I forgot the key" and "I meant to clear it" must not do the same thing.
Time entries
A logged span of work. This is the resource an integration spends most of its time on, so all five operations are here together.
List entries
| Query | Notes |
|---|---|
board_id, item_id, user_id | Platform ids. |
from, to | Bound started_at, half-open. Dates and durations. |
include_running | Include timers still running. Their duration_seconds is null, and their note may already be set — a note can be written while a timer runs. |
cursor, limit, fields | See Pagination and Selecting fields. |
source says how the time was measured — timer for a running
clock, manual for time typed in or written through this API. On a manual entry
ended_at equals started_at: it records a span of work, not a
stopwatch reading, and duration_seconds is the figure that matters.
Log time
platform_user_id is required — a key acts for an account, not a person, so
there is nobody to default to. That person must be a
member of the card's board.
def log_time(card, member, started_at, seconds, run_id, note=None):
"""Idempotent: safe to retry with the same run_id."""
response = client.post(
"/time-entries",
json={
"platform_item_id": card,
"platform_user_id": member,
"started_at": started_at, # must carry a UTC offset
"duration_seconds": seconds,
"note": note,
"external_source": "hours-bridge",
"external_id": run_id,
},
)
if response.status_code == 409:
raise RuntimeError(f"{run_id} already names a different entry")
response.raise_for_status()
return response.json()
entry = log_time(card_id, member_id, "2026-08-12T08:00:00Z", 3600, "run-001", "nightly")
print(entry["id"])
curl -X POST \
-H "Authorization: Bearer $TTP_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
"platform_user_id": "5219e4d1b1a5b8f5a2000123",
"started_at": "2026-08-12T08:00:00Z",
"duration_seconds": 3600,
"note": "nightly",
"external_source": "hours-bridge",
"external_id": "run-001"
}' \
"$BASE/time-entries"
async function logTime(card, member, startedAt, seconds, runId, note = null) {
const response = await fetch(`${BASE}/time-entries`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
platform_item_id: card,
platform_user_id: member,
started_at: startedAt, // must carry a UTC offset
duration_seconds: seconds,
note,
external_source: "hours-bridge",
external_id: runId,
}),
});
if (response.status === 409) throw new Error(`${runId} already names a different entry`);
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
{
"id": 5,
"platform": "trello",
"platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
"platform_board_id": "65a1b2c3d4e5f60718293a4b",
"platform_user_id": "5219e4d1b1a5b8f5a2000123",
"started_at": "2026-08-12T08:00:00Z",
"ended_at": "2026-08-12T08:00:00Z",
"duration_seconds": 3600,
"source": "manual",
"note": "nightly",
"external_source": "hours-bridge",
"external_id": "run-001"
}
Retrying safely
Send external_source and external_id — both, or neither — and the
call becomes safe to repeat. They are your id for the record: a job run, a row in
your own database, whatever recognises the same work twice.
-
The identical request again returns the entry that already exists, with
the same
id. Not a second row. - The same id with anything changed — card, person, start, duration or note — is a 409, never a silent overwrite.
-
Your source name is yours alone. It is namespaced per account behind the
scenes, so
"sync"and"42"can be used by every customer at once. You always read back the source you sent.
{
"detail": {
"code": "external_id_conflict",
"message": "this external_source/external_id already names a different entry; use a new id or patch the existing entry",
"external_source": "hours-bridge",
"external_id": "run-001"
}
}
Correct an entry
{
"duration_seconds": 4500
}
{
"id": 5,
"started_at": "2026-08-12T08:00:00Z",
"ended_at": "2026-08-12T09:15:00Z",
"duration_seconds": 4500,
"source": "manual",
"note": "nightly",
"external_source": "hours-bridge",
"external_id": "run-001"
}
Editing a duration moves ended_at with it, so the span always agrees with the
number — otherwise a report bucketed by day would disagree with its own total.
One field cannot be nulled
duration_seconds is refused with a 422.
A null duration beside a null ended_at is how the tracker records
a timer running right now, so clearing it would forge a running timer out of
finished work. note can be nulled, and that clears it.
A running timer takes a note, not a duration
note can be set on an entry whose timer is still running — the same
thing a person does on the card back while working. duration_seconds on
a running entry is refused with a 409: it is
still accruing, so any number written there is one the timer overwrites. Stop the
timer first, then correct it.
Delete an entry
Reports
Totals grouped by any of board, list, item,
member, day — repeat the parameter to nest them. Needs the
reports feature as well as api.
| Query | Notes |
|---|---|
group_by | Repeatable, in nesting order. Defaults to item. |
tz | Your offset from UTC in minutes. A day boundary is local. |
from, to | Bound started_at, half-open. |
board_id, user_id, list_id, label | Repeatable filters, all platform ids. |
platform | Narrows the whole report, not just id lookups. |
Each cell carries both a key and a label, unlike the in-product
report which shows labels only: two boards can share a name, and a label is not something
to join on. Running entries are never counted — no duration yet.
report = client.get(
"/reports/grouped",
params=[("group_by", "member"), ("group_by", "day"), ("tz", 180)],
).raise_for_status().json()
for row in report["rows"]:
labels = " / ".join(cell["label"] for cell in row["cells"])
print(f"{labels:32} {row['seconds'] / 3600:5.1f} h")
curl -H "Authorization: Bearer $TTP_KEY" \
"$BASE/reports/grouped?group_by=member&group_by=day&tz=180"
const query = new URLSearchParams({ tz: "180" });
query.append("group_by", "member");
query.append("group_by", "day");
const report = await (await fetch(`${BASE}/reports/grouped?${query}`, { headers })).json();
for (const row of report.rows) {
const labels = row.cells.map((cell) => cell.label).join(" / ");
console.log(labels, (row.seconds / 3600).toFixed(1), "h");
}
Ada Lovelace / 2026-08-10 2.0 h Ada Lovelace / 2026-08-11 1.5 h Grace Hopper / 2026-08-11 1.0 h Grace Hopper / 2026-08-12 0.5 h
Two dimensions have no platform id, on purpose
day is a date. list is a normalised name, because the report
deliberately merges same-named lists across boards into one row — six boards each with
a "Jul 2026" are one line, not six identical ones. No single platform list id stands
for that group, so the field says null rather than picking one of six.
If a board, card or member is deleted while the report is being built, the response is 409 report_changed rather than a row keyed by an internal id. Retry.
Limits
Anything outside these is a 422 naming the field, never a surprise from the database.
| Field | Range | Notes |
|---|---|---|
duration_seconds | 1 – 31 622 400 | One second to 366 days, per entry. Cannot be null. |
estimate_seconds | 0 – 315 360 000 | Up to ten years. null clears it. |
note | ≤ 1 024 chars | null clears it. |
result | ≤ 10 000 chars | null clears it. |
ai_result_formatted | ≤ 10 000 chars | Stamps ai_result_formatted_at. |
ai_result_recommended_hours | 0 – 99 999 999.99 | Decimal, sent and returned as a string. |
ai_result_verification | ≤ 65 536 bytes | A JSON object, measured as compact UTF-8. |
external_source | ≤ 16 chars | Namespaced per account internally. |
external_id | ≤ 128 chars | Required whenever external_source is sent. |
limit | 1 – 500 | Defaults to 100. |
fields | 1 – 50 names | Comma-separated or repeated. Omit for the whole object. |
tz | −840 – 840 | Minutes from UTC. |
| Requests | 120 / minute | Per key. See Rate limits. |