Time Tracker Pro / Public API

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"])
Output
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.

Header
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.

401 response
{
  "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.

WhatNeeds
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"])

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"])
Response
{
  "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:

RequestRawGzipped
Whole object6 746 B296 B
With fields=2 071 B190 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.

422 — a start without an offset
{
  "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

StatusCodeWhat happened
401—Key missing, malformed, unknown, revoked or expired — deliberately one answer.
403feature_requiredThe account does not have the api, reports or ai feature. Write to [email protected] to have it enabled.
403read_only_keyThe key was issued without write access.
404—No such thing, or it exists outside this key's scope.
409ambiguous_platformThat native id exists on two platforms. Add ?platform=.
409external_id_conflictThat external id already names a different entry.
409item_elsewherePOST /items named a card another board already holds.
409report_changedSomething in the report was deleted while it was being built. Retry.
422unknown_fieldsfields= named something not on the object.
422—The payload is outside the documented bounds.
429rate_limitedOut 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()

Rate limits

120 requests a minute per key. Every response to an authenticated call carries the budget, the 429 included.

Response headers
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.

GET /boards 200
Response
{
  "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.

QueryNotes
platformNarrow to one platform.
cursor, limitPagination.
fieldsSelecting 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.

GET /boards/{platform_board_id}/lists 200
Response
{
  "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.

GET /boards/{platform_board_id}/members 200
Response
{
  "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.

POST /items 201 created, 200 already there
FieldNotes
platform_board_idRequired. Must be a board this key reaches, or 404.
platform_item_idRequired. The card's own id, as the platform returned it.
nameRequired. Stored exactly as sent.
platform_list_id, list_nameOptional, both or neither. Creates or renames the board's list.
platformOnly 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")
Response — the card, exactly as GET /items/{id} returns it
JSON
{
  "platform": "trello",
  "platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f77",
  "platform_board_id": "65a1b2c3d4e5f60718293a4b",
  "name": "GR: Formats: nightly rebuild",
  "platform_list_id": "65a1b2c3d4e5f6071829d001",
  "labels": [],
  "estimate_seconds": null,
  "logged_seconds": 0,
  "result": null,
  "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
}

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.

Refused — 409 a card another board holds
JSON
{
  "detail": {
    "code": "item_elsewhere",
    "message": "item 6b0c1d2e3f4a5b6c7d8e9f01 already belongs to another board; a card is moved by opening it in the Power-Up, not through this API",
    "platform_board_id": "65a1b2c3d4e5f60718293a4b"
  }
}

platform_board_id names the holder only when it is a board this key can already read. Outside your scope the key is absent, not null.

List cards

GET /items 200
QueryNotes
board_idA platform_board_id.
list_idA platform_list_id.
labelRepeatable; matches any of them.
platformNarrow to one platform.
cursor, limit, fieldsSee Pagination and Selecting fields.

Get one card

GET /items/{platform_item_id} 200

Six fields depend on the ai feature. The same card, the same key, without it and with it:

Without ai
{
  "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
}
With ai
{
  "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

PUT /items/{platform_item_id}/estimate 200 the whole card
card = client.put(
    f"/items/{card_id}/estimate",
    json={"estimate_seconds": 36000},   # None clears it
).raise_for_status().json()

print(card["estimate_seconds"])   # 36000

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.

PUT /items/{platform_item_id}/result 200 the whole card
Request body
{
  "result": "Rewrote the incremental load; nightly job is stable."
}

Write the AI fields

PUT /items/{platform_item_id}/enrichment ai feature
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

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

GET /time-entries 200
QueryNotes
board_id, item_id, user_idPlatform ids.
from, toBound started_at, half-open. Dates and durations.
include_runningInclude 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, fieldsSee Pagination and Selecting fields.
Response — three entries on one card
JSON
{
  "items": [
    {
      "id": 1,
      "platform": "trello",
      "platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
      "platform_board_id": "65a1b2c3d4e5f60718293a4b",
      "platform_user_id": "5219e4d1b1a5b8f5a2000123",
      "started_at": "2026-08-10T09:00:00Z",
      "ended_at": "2026-08-10T09:00:00Z",
      "duration_seconds": 7200,
      "source": "manual",
      "note": "schema review",
      "external_source": null,
      "external_id": null
    },
    {
      "id": 2,
      "platform": "trello",
      "platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
      "platform_board_id": "65a1b2c3d4e5f60718293a4b",
      "platform_user_id": "5219e4d1b1a5b8f5a2000123",
      "started_at": "2026-08-11T09:00:00Z",
      "ended_at": "2026-08-11T09:00:00Z",
      "duration_seconds": 5400,
      "source": "manual",
      "note": null,
      "external_source": null,
      "external_id": null
    },
    {
      "id": 3,
      "platform": "trello",
      "platform_item_id": "6b0c1d2e3f4a5b6c7d8e9f01",
      "platform_board_id": "65a1b2c3d4e5f60718293a4b",
      "platform_user_id": "5219e4d1b1a5b8f5a2000456",
      "started_at": "2026-08-11T09:00:00Z",
      "ended_at": "2026-08-11T09:00:00Z",
      "duration_seconds": 3600,
      "source": "manual",
      "note": "pairing",
      "external_source": null,
      "external_id": null
    }
  ],
  "next_cursor": null
}

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.

POST /time-entries 200 the entry
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"])
Response
{
  "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.
409 — same id, duration changed
{
  "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

PATCH /time-entries/{id} 200 the entry
Request
{
  "duration_seconds": 4500
}
Response — the note is untouched
{
  "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

DELETE /time-entries/{id} 204 no body

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.

GET /reports/grouped 200
QueryNotes
group_byRepeatable, in nesting order. Defaults to item.
tzYour offset from UTC in minutes. A day boundary is local.
from, toBound started_at, half-open.
board_id, user_id, list_id, labelRepeatable filters, all platform ids.
platformNarrows 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")
Output
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
Response — first two of four rows
JSON
{
  "group_by": ["member", "day"],
  "rows": [
    {
      "cells": [
        { "dimension": "member", "platform": "trello", "key": "5219e4d1b1a5b8f5a2000123", "label": "Ada Lovelace" },
        { "dimension": "day", "platform": null, "key": "2026-08-10", "label": "2026-08-10" }
      ],
      "seconds": 7200,
      "estimate_seconds": 28800
    },
    {
      "cells": [
        { "dimension": "member", "platform": "trello", "key": "5219e4d1b1a5b8f5a2000456", "label": "Grace Hopper" },
        { "dimension": "day", "platform": null, "key": "2026-08-11", "label": "2026-08-11" }
      ],
      "seconds": 3600,
      "estimate_seconds": 28800
    }
  ],
  "total_seconds": 18000,
  "estimate_seconds": 28800
}

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.

FieldRangeNotes
duration_seconds1 – 31 622 400One second to 366 days, per entry. Cannot be null.
estimate_seconds0 – 315 360 000Up to ten years. null clears it.
note≤ 1 024 charsnull clears it.
result≤ 10 000 charsnull clears it.
ai_result_formatted≤ 10 000 charsStamps ai_result_formatted_at.
ai_result_recommended_hours0 – 99 999 999.99Decimal, sent and returned as a string.
ai_result_verification≤ 65 536 bytesA JSON object, measured as compact UTF-8.
external_source≤ 16 charsNamespaced per account internally.
external_id≤ 128 charsRequired whenever external_source is sent.
limit1 – 500Defaults to 100.
fields1 – 50 namesComma-separated or repeated. Omit for the whole object.
tz−840 – 840Minutes from UTC.
Requests120 / minutePer key. See Rate limits.

Every payload and Python snippet on this page was executed against a running server — nothing here is written from memory.

Questions, or a key: [email protected].