Reports
A report is a frozen document: a template rendered against a set of filters (workspaces, projects, users, tags, and a date range) for one fixed period. Rendering happens synchronously when you create or edit a report — the resulting Markdown is stored as an immutable, versioned snapshot, so the document never changes underneath a reader, a public share, or a delivered copy.
A report is owned by the user who created it. Relative date ranges (this_week, …) and the
generation date resolve in the owner’s timezone at render time. See
Dates and time.
The report object
Section titled “The report object”| Field | Type | Description |
|---|---|---|
id |
string | Unique identifier. |
name |
string | Display name (1–100 chars). |
ownerUserId |
string | The user who created the report. |
templateId |
string | The template rendered into this report. Not a foreign key — a report survives its template being deleted. |
filters |
object | The resolved filter set (see The filters object). The stored dateRange is always absolute. |
note |
string | null | Free-form Markdown appended to the rendered body via {{ report.note }} (≤ 20000 chars). |
totalMinutes |
integer | null | Total logged minutes across the current version’s entries. null for reports rendered before this was tracked (shown until re-rendered). |
snapshotProjectIds |
string[] | null | Distinct project ids whose entries appear in the current snapshot, captured at render time. Drives the Send-to ACL. null means unknown scope (rendered before this was captured) — see Send a report. Serialized on the wire but not part of the typed reportSchema in @spantail/core, so treat it as an extra wire-only field. |
version |
integer | Current version: 1 at creation, incremented on each edit. |
reportContentId |
string | The current content version’s id — the key for content-scoped resources such as the version’s discussion. |
renderedMarkdown |
string | The current version’s rendered document: a system-generated YAML front-matter header (provenance) followed by the Liquid-rendered body. |
createdAt |
string | ISO 8601 creation instant. |
updatedAt |
string | ISO 8601 last-update instant. |
List payloads omit reportContentId and renderedMarkdown; fetch them on demand with
GET /reports/:id.
The filters object
Section titled “The filters object”| Field | Type | Required | Description |
|---|---|---|---|
workspaceIds |
string[] | yes | 1–20 workspaces. A cross-workspace report is limited to the union of the caller’s memberships. |
projectIds |
string[] | no | Up to 50 projects to scope to. |
userIds |
string[] | no | Up to 50 authors to include. Omitted, the report covers only the caller’s own entries — this is the default and what the web app always does. Listed authors are still bounded by the caller’s access (you can only include entries you may read). |
tags |
string[] | no | Up to 20 tags; an entry matches if it carries any of them. |
dateRange |
object | string | yes | On the wire, either a preset string or an absolute range. On a stored report, always an absolute { from, to }. |
dateRange accepts either a preset — one of today, yesterday, last_7_days, last_30_days,
this_week, last_week, this_month, last_month (resolved server-side in the owner’s timezone;
weeks start Monday) — or
an absolute range { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" } (inclusive; from ≤ to,
spanning at most 366 days). Whichever you send, the stored report keeps the resolved absolute range.
List reports
Section titled “List reports”GET /api/v1/reports scope: read
Returns report metadata (no rendered body), newest first. By default the caller reads their own
reports. Instance admins can read another user’s set with ownerUserId; a workspace admin/owner
can read a single workspace’s reports with workspaceId (only reports scoped to exactly that one
workspace). For the owner’s own list, totalMinutes is redacted to null on any report whose
workspaces the owner no longer fully belongs to.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
ownerUserId |
string | no | Instance admin only: read this user’s reports. |
workspaceId |
string | no | Workspace admin/owner: read this workspace’s single-workspace reports. |
templateId |
string | no | Filter to reports built from a template. |
projectId |
string | no | Filter to reports scoped to a project. |
from |
string | no | Keep reports whose period overlaps on/after this date YYYY-MM-DD. |
to |
string | no | Keep reports whose period overlaps on/before this date YYYY-MM-DD. |
limit |
integer | no | Page size, 1–200. Omitted returns the full filtered set. |
offset |
integer | no | Rows to skip (default 0). |
List template ids in use
Section titled “List template ids in use”GET /api/v1/reports/template-ids scope: read
Returns the distinct template ids the caller has reports for, so a UI can surface the relevant template tabs without loading every report.
Preview a report
Section titled “Preview a report”POST /api/v1/reports/preview scope: read
Renders a report without persisting it — the compose dialog’s live preview. It runs the same membership and template validation and the same render as create, so the preview matches what would be stored. Read scope is enough because nothing is written.
The request body is identical to Create a report.
suggestedName and suggestedNote are the template’s nameTemplate / noteTemplate rendered
against this scope and period — what the compose form pre-fills. Both are empty when the template
defines no such default.
Create a report
Section titled “Create a report”POST /api/v1/reports scope: write
Renders the document and persists it as version 1. The caller must belong to every workspace in
filters.workspaceIds. The template must exist and be enabled — a disabled template returns
400. A template rendering error also returns 400, so a report is never persisted in an
un-renderable state.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Display name (1–100 chars). |
templateId |
string | yes | Template to render. |
filters |
object | yes | Filter set (see The filters object). |
note |
string | no | Free-form Markdown appended to the body (≤ 20000 chars). Blank collapses to null. |
Returns 201 Created.
Get a report
Section titled “Get a report”GET /api/v1/reports/:id scope: read
Returns a single report including its renderedMarkdown. Readable by the owner, an instance admin,
or a workspace admin/owner of the report’s single workspace (only when the report is scoped to
exactly one workspace; a multi-workspace report stays instance-admin-only). For the owner, the
rendered body is workspace data — losing membership in any filtered workspace revokes access.
Anyone else gets 404.
Update a report
Section titled “Update a report”PATCH /api/v1/reports/:id scope: write
Owner only. Editing changes the report’s fields, re-renders the document, and appends a new
immutable content version — version is bumped by one. The request body is the same shape as
create (all of name, templateId, and filters are sent). The same gates as GET /:id apply:
the owner must still belong to every filtered workspace, the template must be enabled, and a render
error returns 400.
Delete a report
Section titled “Delete a report”DELETE /api/v1/reports/:id scope: write
Owner only. Shares (references to the report’s versions) and deliveries (frozen copies) cascade
away with the report. Returns 204 No Content.
Recipients
Section titled “Recipients”GET /api/v1/reports/:id/recipients scope: read
Owner only. Returns the candidate recipients for Send: the union of members
across the report’s workspaces (people already entitled to its data), minus the sender, filtered to
those who can read every project captured in snapshotProjectIds (a workspace admin/owner reads
any project; otherwise the recipient must be a member of each). A report with only unassigned/own
entries imposes no project restriction. If snapshotProjectIds is null (unknown scope), no one is
eligible until the report is re-rendered.
Send a report
Section titled “Send a report”POST /api/v1/reports/:id/send scope: write
Owner only. Drops the report’s current immutable version into each recipient’s inbox. Each
recipient must be in the recipients candidate set, so a send can never widen who
can see the data; an ineligible id returns 400. Each delivery references the sent version, so
the recipient keeps what was sent even after a later edit (which appends a new version); deleting
the report removes its deliveries.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
recipientUserIds |
string[] | no | Up to 50 recipients. Defaults to []. |
sendToSelf |
boolean | no | Also drop a copy into the sender’s own inbox. Defaults to false. |
message |
string | no | Short note for the recipients (≤ 1000 chars). |
At least one recipient or sendToSelf must be present.
Returns 201 Created. delivered counts only teammate recipients; a self-copy is not counted.
Send history
Section titled “Send history”GET /api/v1/reports/:id/sends scope: read
The report’s send history — one entry per Send batch, newest first. Same read access as
recipients and shares (owner, instance admin, or an admin of the report’s
workspace). readCount is the recipient’s own read state and is redacted to 0 for anyone
but the owner, so an admin reader can’t see whether a single recipient opened a send.