Timjami does not push anywhere and runs no scheduler. If you want valued hours in
Elasticsearch — or in any other analytics store — you run a cron next to that store, pull
from two endpoints, and write what they hand you. Timjami holds no credentials into your
index, and the token you point at it is read-only.
The unit
One document is one valued stretch of one finished time entry: person, task, project,
customer, budget, rate, amount, currency, and a status. Not one per entry — an entry
whose first hours are billed by a transferred invoice while the rest is still open is two
documents, and one straddling a budget's capacity boundary is two as well. Non-billable
time is exported too, with no rate and no amount: the billable rate (the share of tracked
time that was billable) needs it as its denominator.
The id is <month>:<entryId>:<fromSecond> — the month, the entry, and the stretch's start
offset in seconds within that entry. It guarantees the start, the month and uniqueness; it
does not guarantee the end. A moved allocation boundary is a tombstone plus a new
document rather than an update, because the old id described seconds nobody priced that way.
Two endpoints
# Everything in one month, complete. This is a first sync, a rebuilt index,
# and the nightly full run — the same code path each time.
curl -s 'https://timjami.app/api/v1/reports/projection?month=2026-07' \
-H 'Authorization: Bearer thyp_…'
# Everything that changed since your last pull, tombstones included.
curl -s 'https://timjami.app/api/v1/reports/projection/changes?since=2026-08-13T02:00:00.000Z' \
-H 'Authorization: Bearer thyp_…'
Both need a token with thymian:reports.read, held by somebody with the owner or admin
role. That is not bureaucracy: those roles see private projects, and a token belonging to
anybody else would produce an index quietly missing one of them.
Both recompute and store on the way through. Every month is recomputed on every run,
diffed against what was last delivered, and the new state persisted — a value here moves
without its time entry being touched (a dated rate lands, a budget is corrected, a
colleague's backdated hour shifts the allocation), so there is no "this month cannot have
changed" shortcut, and asking is what makes the answer current.
The loop
// GET /api/v1/reports/projection/changes?since=…
{
"data": [
{
"id": "2026-07:0f4d…:0",
"month": "2026-07",
"changedAt": "2026-08-14T02:00:03.412Z",
"deleted": false,
"document": { "id": "2026-07:0f4d…:0", "seconds": 3600, "amountMinor": 9000, "status": "final", /* … */ }
},
{ "id": "2026-06:1a7c…:0", "month": "2026-06", "changedAt": "…", "deleted": true, "document": null }
],
"nextCursor": null,
"nextSince": "2026-08-14T02:00:03.412Z",
"fullResyncRecommended": false
}
- Start from the full export, month by month, and keep its
syncedAt.
- From then on call
/changes with since set to the stored stamp.
- Page while
nextCursor is not null, passing it back as cursor with the same
since. When it is null the delta is complete.
- Write your batch. Then store
nextSince as your new cursor.
Step 4 in that order is the whole reliability story. The cursor is inclusive and answers
from stored documents rather than from the run's diff, so a job that dies mid-write asks
again with the same since and is told the same thing. Every id is an upsert, so the
repeat costs nothing. Advancing the cursor first is how a crash loses a document silently.
deleted is not optional
A document with deleted: true means delete that id from your index. It carries no
body. Skip this branch and every hour ever deleted or re-dated in Timjami stays in your
dashboards forever, looking exactly like an hour somebody worked. A re-dated entry arrives
as precisely this pair: a tombstone in the old month, a document in the new one.
Tombstones are kept for 30 days, which is therefore exactly how far back a since may
reach. Ask with an older one and the answer carries fullResyncRecommended: true — call
the full export instead of trusting that delta, because what disappeared while you were
gone can no longer be named.
More than one destination
Nothing here is registered, so any number of jobs may pull. The cursor lives on your side:
/changes?since=T is a function of T and the stored state — every stored document at or
after T, not the diff of the run that happens to answer — so one job pulling does not
consume anything another job has not fetched yet. Two destinations each keep their own
since and neither notices the other.
Three things are shared, and none of them couples the consumers. Each pull recomputes, so
five jobs mean five recomputes of the same months (harmless — the valuation is
deterministic, concurrent pulls converge — but it is work). The 30-day tombstone horizon is
absolute rather than measured from anybody's last pull, so a fast consumer never shortens a
slow one's window. And the state is a snapshot rather than a history: you always receive a
document's current value, never the intermediate states you were offline for. That is the
right shape for upserting into an index and the wrong shape for keeping a change log.
A nightly full run
Keep one, even though every pull already reconciles everything. It insures against the
consumer side — a rebuilt or re-mapped index, a bug in your own write path — and it is
the same request as a first sync, so it costs nothing to keep:
#!/usr/bin/env bash
set -euo pipefail
month="$(date -u +%Y-%m)"
cursor=""
while :; do
page=$(curl -sf -H "Authorization: Bearer $THYMIAN_TOKEN" \
"$THYMIAN/api/v1/reports/projection?month=$month&limit=1000${cursor:+&cursor=$cursor}")
# One `{"index": …}` action line per document, then the document itself.
jq -c '.data[] | {index: {_index: "thymian-hours", _id: .id}}, .' <<<"$page" \
| curl -sf -H 'Content-Type: application/x-ndjson' --data-binary @- \
"$ES/_bulk" > /dev/null
cursor=$(jq -r '.nextCursor // empty' <<<"$page")
[ -n "$cursor" ] || break
done
The delta's write is the same, with the tombstones split off:
jq -c '.data[] | select(.deleted | not) | {index: {_index: "thymian-hours", _id: .id}}, .document' <<<"$page"
jq -c '.data[] | select(.deleted) | {delete: {_index: "thymian-hours", _id: .id}}' <<<"$page"
Reading the numbers
status is the field every dashboard has to carry. final is a stretch a transferred
invoice claims — money that has left Timjami for the invoicing tool and cannot change here
any more. Everything else is provisional, including a draft invoice's hours, and it is
provisional in the literal sense: it will move if somebody reprices the project or a
colleague backdates an hour into the same budget.
It is also not monotonic. Withdrawing an invoice releases its claims, and those seconds
go back to provisional at a recomputed value. A dashboard that sums final and
provisional money into one unlabelled number is stating something nobody can defend;
facet on the field instead.
Two more, both small and both the kind of thing that is found late:
rateMinor: null means nothing priced those seconds. It is never 0. Billable hours
with no rate are a real state and worth a panel of their own.
- A document rounds over its own seconds, while an invoice line rounds once over its whole
quantity. Summing
final money therefore approximates what was invoiced, to within a
minor unit per document. If you need the invoiced total, read the invoice.
Finally, the projection is disposable at both ends. Timjami never reads its own sync state
back for anything but this export, and your index can be dropped and rebuilt from the full
export at any time.