The Reports API is in beta. The contract is stable enough to build against, but endpoints and datasets may still change in response to feedback before general availability. Tell us what you build — and what’s missing — at hello@flowpayroll.ai.
/v1/report base path, relative to the environment base URLs documented on the API reference introduction.
What you can do
- Discover datasets — list the queryable datasets and inspect each one’s field catalog before you write a definition.
- Run reports synchronously — post an inline definition (or run a saved one) and get columns and rows straight back, capped at 10,000 rows.
- Save definitions — store a definition under a name unique to your organisation, then re-run, export, or schedule it by ID.
- Export large result sets — queue an asynchronous export to CSV or JSONL for up to 5,000,000 rows and download it from a presigned URL.
- Schedule recurring reports — run a saved definition on a cron schedule and email the export to the recipients you choose.
- Be told when a report is ready — subscribe a webhook endpoint to the
report_export.completedandreport_export.failedevents, sent for every export whether you queued it or a schedule did. - Review recent runs — list run history, newest first, with the mode, status, row count and timing of each one.
Key concepts
- Dataset — a queryable view of payroll data, such as payslips, payslip lines, or payroll runs, plus satellite datasets covering statutory payments, court orders, holiday, payments, recurring line items, pensions, journals, invoices, and clients.
GET /datasetslists them. - Field catalog — every dataset field with its type and capabilities: whether it is filterable, groupable, which aggregations it supports, and whether it is sensitive. Dotted field names (for example
employee.department) reach related entities. Access to sensitive fields is recorded in the run audit trail. - Report definition — the JSON document describing a report: the
dataset, 1–50selectentries, an optionalfilterstree,groupBy,orderBy, and alimit. One contract drives sync runs, exports, saved definitions, and schedules. - Run — one execution of a definition, in
SYNC,EXPORT, orSCHEDULEDmode. Every one is recorded in the history served byGET /runs.SYNCrows are kept for 90 days;EXPORTandSCHEDULEDrows have a file behind them and are kept indefinitely. - Export — an asynchronous run that writes the result set to a file (CSV or JSONL) and hands you a time-limited download URL.
- Schedule — a cron expression attached to a saved definition, with email recipients (
emailsas the To line, plus optionalccandbcc) and an output format.
API structure
Datasets
Start here:GET /datasets returns the dataset summaries, and GET /datasets/{name} returns the full field catalog for one dataset — the names, types, and capabilities your report definition is validated against.
Run reports
POST /reports/run executes an inline report definition synchronously and returns the result set: typed columns, rows, a row count, and the execution time. Synchronous runs are capped at 10,000 rows — the response’s truncated flag tells you the cap was hit, and the fix is an export. A definition that takes too long returns 408; narrow the filters or use an export instead. One organisation may have four synchronous runs in flight at once — a fifth returns 429, and the answer is an export rather than a retry loop, since exports are not limited this way. POST /report-definitions/{id}/run does the same for a saved definition.
Report definitions
Save a definition withPOST /report-definitions (the name must be unique within your organisation), list them with GET /report-definitions, and retrieve, replace, or delete one with GET, PUT, and DELETE on /report-definitions/{id}. Deleting refuses with 409 while schedules still reference the definition — delete the schedules first.
Exports
POST /exports queues an asynchronous export of either a saved definition (definitionId) or an inline definition — exactly one of the two — in CSV or JSONL format, with a row cap of 5,000,000. The request is accepted with status QUEUED; poll GET /exports/{id} until the status reaches COMPLETED, then follow the presigned downloadUrl. Download URLs expire after 12 hours — refetch the export to re-issue one. Export files themselves are kept for 30 days.
Schedules
POST /report-definitions/{definitionId}/schedules runs a saved definition on a 5-field cron expression (timezone defaults to Europe/London) and emails the export to the emails (To) recipients, with optional cc and bcc. List a definition’s schedules with GET /report-definitions/{definitionId}/schedules and remove one with DELETE /schedules/{id}.
To have another system told when a report is ready, subscribe a webhook endpoint to report_export.completed (and report_export.failed). Every export run sends one — a run a schedule started as much as one you queued with POST /exports — carrying the runId, mode (EXPORT or SCHEDULED), definitionId, scheduleId, format, status and rowCount. The event never carries a download link: its resourceUrl is GET /exports/{runId}, which answers your authenticated request with a fresh one. Route on scheduleId if you only want scheduled reports.
The schedule’s own delivery.webhookUrl is deprecated since 1 October 2026 and stops being accepted as input on 30 December 2026. Until then a schedule that carries one still receives the legacy X-Flow-Signature POST exactly as before.
Each schedule reports how its last report actually landed: lastDeliveryStatus (DELIVERED or FAILED), lastDeliveryAt, lastDeliveryError, and consecutiveDeliveryFailures, which resets to 0 on the first success — so it reads as “failing since” rather than “has ever failed”. A run that never produced a file records a FAILED delivery too, so a schedule cannot keep showing its last success while its reports have been failing. Poll this rather than waiting to notice a report that stopped arriving.
An email that is accepted and then refused is recorded here as well. Handing the message to our email provider is not delivery: an address that is suppressed — one an earlier message bounced from, or that marked us as spam — is refused later and permanently. That outcome comes back asynchronously, so lastDeliveryStatus can move from DELIVERED to FAILED minutes after the run finished. Treat a completed run as “the file exists”, not as “the recipient has it”.
Run history
GET /runs returns the organisation’s run history, most recent first — every synchronous run, export, and scheduled run, with its status, row count, requester, and timestamps. It doubles as the audit trail: runs that touched sensitive fields are recorded here.
?mode=SYNC|EXPORT|SCHEDULED narrows it to one kind, which matters because synchronous runs are by far the most numerous and would otherwise push exports off the first page. limit defaults to 50 and is clamped to 200.
requestedBy is the caller that started the run, carrying the kind of principal it was — USER#… for a person, M2M#… for an API client, and the literal scheduler for a run a schedule started. It is null for runs made before this was recorded.
The report definition document
A definition selects from one dataset and combines five building blocks:select— 1–50 entries. Each entry is a plain field ({"field": "employee.department"}), an aggregation ({"aggregate": "sum", "field": "amount", "as": "total"}—sum,avg,min,max, orcount, with optionaldistincton a counted field), or a tag-value extraction from a tags field.filters— a tree ofand/or/notbranches (up to 3 levels deep) over field leaves. Operators:eq,neq,in,gt,gte,lt,lte,between,contains(case-insensitive text),isNull, andinPeriod. Values are type-checked against the field.groupBy— required as soon as any select entry aggregates; every plain select entry must then appear in it.orderBy— sort by any selected field or aggregation alias, ascending or descending.limit— clamped to 10,000 for synchronous runs and 5,000,000 for exports.
400 with a JSON-pointer path for each problem, so you can point the user at the exact offending entry.
Relative periods
inPeriod on a date field takes a relative-period token — @currentWeek, @currentMonth, or @currentTaxYear — and expands it to the full range at run time. Scheduled runs resolve the token against the schedule’s fire date, so a saved “this month’s payroll” report tracks the moving period without ever being edited.
Payslip status scope
Unless a definition filters onstatus explicitly, an implicit status = "FINAL" filter is applied — draft (Preview) figures never leak into a report by accident. To report on an unlocked run’s drafts, filter for PREVIEW (or in both). The response’s statusScope echoes which scope applied.
Typical workflow
- Call
GET /datasets, thenGET /datasets/{name}, to find the dataset and fields you need. - Iterate on a definition with
POST /reports/rununtil the columns and totals look right. - Save it with
POST /report-definitionsonce it’s stable. - For the full result set, queue
POST /exportsand pollGET /exports/{id}for the download URL. - To receive it recurrently, create a schedule with
POST /report-definitions/{definitionId}/schedules; to be told in code, subscribe a webhook endpoint toreport_export.completed. - Review what ran — and who ran it — with
GET /runs.
Notes and best practices
- Decimal values arrive as strings in the response rows — parse them with a decimal-safe type, not a float.
- If a synchronous response has
truncated: true, don’t page around it — the export path exists precisely for that result set. - The organisation is always taken from your credentials.
organisation_idis not a selectable or filterable field, and it cannot be overridden in a definition. - YTD fields can’t be aggregated with
sumoravg(onlymin/max) — summing running totals double-counts. - Tags fields (arrays of
group:name:valuestrings) only supportcontainsandisNullfilters, and aren’t groupable — extract a tag’s value into a column via a select entry to group by it. delivery.webhookUrlandwebhookSecreton schedules are deprecated — see Deprecations. Subscribe to thereport_export.*webhook events instead.
Authentication
Every request must carry a valid token in theX-Auth-Token header, and credentials with access to more than one organisation must also send X-Org-Id — see Authentication.