API reference · v1
Read your TimeFlov data from your own tools
The TimeFlov API gives your scripts, BI tools and integrations read access to projects, clients, hours, customer invoices and expenses. It is read-only, answers in JSON and only ever sees your own company.
- Create a key. An admin creates one in TimeFlov under Admin, API keys, and picks what it may read. The key is shown once, so store it right away.
- Send it with every request as a bearer token in the
Authorizationheader. - Page through the results by sending
next_cursorback ascursoruntil it isnull.
https://api.timeflov.com/v1curl https://api.timeflov.com/v1/projects \
-H "Authorization: Bearer $TIMEFLOV_API_KEY"Authentication#
Every request carries a key in the Authorization header. Keys start with tf_live_.
A key reads only what its scopes allow, such as read:projects. A request for anything else is refused with scope_missing.
A key stops working when an admin revokes it, when it passes its expiry date, or when the admin who created it is deactivated or is no longer an admin.
Keep keys on a server. Anyone holding a key can read what it allows, so never put one in a web page or in an app people download.
The API is included in the Enterprise plan and during the free trial.
Authorization: Bearer tf_live_…Pagination#
Lists come back in id order, 100 rows at a time. Pass limit for up to 200. While next_cursor is not null, send it back as cursor to get the next page. The last page can be empty.
To pick up only what changed, pass updated_since with an ISO 8601 time. Projects, time entries, customer invoices and expenses accept it. Clients do not, so read that list in full.
A deleted row simply stops appearing, so a full read now and then is how a sync notices deletions.
const api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const headers = { Authorization: `Bearer ${key}` };
const rows = [];
let cursor = null;
do {
const query = new URLSearchParams({ limit: "200" });
if (cursor) query.set("cursor", cursor);
const url = `${api}/time_entries?${query}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error((await res.json()).code);
const page = await res.json();
rows.push(...page.data);
cursor = page.next_cursor;
} while (cursor);import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
headers = {"Authorization": f"Bearer {key}"}
rows, cursor = [], None
while True:
params = {"limit": 200}
if cursor:
params["cursor"] = cursor
r = requests.get(
f"{API}/time_entries",
headers=headers,
params=params,
timeout=30,
)
r.raise_for_status()
page = r.json()
rows += page["data"]
cursor = page["next_cursor"]
if not cursor:
breakData formats#
Ids are UUIDs. Dates are YYYY-MM-DD. Times are ISO 8601 in UTC, such as 2026-10-13T14:22:07.512930+00:00.
Amounts are plain numbers in the currency named on the same record, and each amount field says whether it includes VAT. An expense report's total_amount adds its lines without converting them, so read each line's own currency.
Every field listed for a resource is always present. A field without a value is null. New fields may be added to v1, so ignore any you do not recognise.
Rate limits#
Each key may make 600 requests per clock minute. Every answer to a valid key carries X-RateLimit-Limit and X-RateLimit-Remaining.
Past the limit the API answers 429 with a Retry-After header: the seconds until the next minute starts.
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587Errors#
Every refusal is JSON with a stable code and an error sentence for people. Build on the code, since the sentence may change.
| Status | Code | Meaning |
|---|---|---|
400 | bad_cursor | cursor is not an id from next_cursor. |
400 | bad_limit | limit is not a whole number between 1 and 200. |
400 | bad_updated_since | updated_since is not an ISO 8601 time, or the resource does not support it. |
401 | invalid_key | The key is missing, malformed, unknown, revoked or expired. |
402 | plan_required | The company's plan does not include the API. |
402 | subscription_inactive | The company's subscription has lapsed. |
403 | scope_missing | The key may not read this resource. |
404 | unknown_resource | There is no such resource in v1. |
405 | method_not_allowed | Only GET is supported. |
429 | rate_limited | Too many requests this minute. Wait the seconds in Retry-After. |
500 | read_failed | The read failed. Retry shortly. |
{
"error": "limit must be an integer between 1 and 200.",
"code": "bad_limit"
}List projects#
/v1/projectsEvery project in your company with its billing terms. Rates and prices exclude VAT.
Needs a key with read:projects
Query parameters
| Parameter | Description |
|---|---|
limitinteger | Rows per page, 1 to 200. 100 when left out. |
cursoruuid | The next_cursor from the previous page. |
updated_sincedate-time | Only projects changed at or after this time. |
Fields
| Field | Description |
|---|---|
iduuid | The project's id. |
client_iduuid | The client the project belongs to, an id from clients. |
namestring | The project's name. |
descriptionstring or null | Free text about the project. |
statusstring | Where the project is in its life.One of active, on_hold, completed or archived. |
project_typestring | How the project is billed: by the hour, at a fixed price, or as a recurring fee.One of time_material, fixed_price or retainer. |
approval_statestring | Whether the project has been approved.One of approved, pending or rejected. |
budget_hoursnumber | Hours budgeted for the project. |
used_hoursnumber | Hours reported on the project so far. |
hourly_ratenumber | The project's default hourly billing rate. A price list or a team member's own rate takes precedence when set. |
fixed_pricenumber or null | The agreed price, on a fixed-price project. |
retainer_amountnumber or null | The fee per period, on a retainer project. |
retainer_periodstring or null | How often a retainer is billed.Either monthly or quarterly. |
retainer_start_datedate or null | The first day of the first retainer period. |
is_billableboolean | Whether time on the project is billed to the client. |
start_datedate | The project's first day. |
end_datedate or null | The project's last day, when one is set. |
created_atdate-time | When the project was created. |
updated_atdate-time | When the project last changed. updated_since compares with this. |
curl -G https://api.timeflov.com/v1/projects \
-H "Authorization: Bearer $TIMEFLOV_API_KEY" \
-d limit=100 \
-d updated_since=2026-10-01T00:00:00Zconst api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const query = new URLSearchParams({
limit: "100",
updated_since: "2026-10-01T00:00:00Z",
});
const url = `${api}/projects?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const { data, next_cursor } = await res.json();import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
r = requests.get(
f"{API}/projects",
headers={"Authorization": f"Bearer {key}"},
params={
"limit": 100,
"updated_since": "2026-10-01T00:00:00Z",
},
timeout=30,
)
r.raise_for_status()
page = r.json(){
"data": [
{
"id": "5f0c2a9e-41d7-4b8e-9a51-0d6c3e2f7b10",
"client_id": "a3e91f04-6c2b-4f55-8d0e-7b19c4d2e6a8",
"name": "Data platform rollout",
"description": "Phase 2: ingestion and reporting",
"status": "active",
"project_type": "time_material",
"approval_state": "approved",
"budget_hours": 640,
"used_hours": 412.5,
"hourly_rate": 1250,
"fixed_price": null,
"retainer_amount": null,
"retainer_period": null,
"retainer_start_date": null,
"is_billable": true,
"start_date": "2026-02-02",
"end_date": "2026-12-18",
"created_at": "2026-01-20T09:14:02.381104+00:00",
"updated_at": "2026-10-13T14:22:07.512930+00:00"
}
],
"next_cursor": null
}List clients#
/v1/clientsYour clients and how they are invoiced. Clients have no updated_at, so read the whole list each time.
Needs a key with read:clients
Query parameters
| Parameter | Description |
|---|---|
limitinteger | Rows per page, 1 to 200. 100 when left out. |
cursoruuid | The next_cursor from the previous page. |
Fields
| Field | Description |
|---|---|
iduuid | The client's id. |
namestring | The client's name. |
org_numberstring or null | The client's organisation number. |
contact_namestring or null | The contact person. |
contact_emailstring or null | The contact person's email address. |
addressstring or null | Street address. |
citystring or null | City. |
zip_codestring or null | Postal code. |
countrystring | Country code, such as SE. |
payment_termsnumber | Days until an invoice to this client is due. |
currencystring | The currency the client is invoiced in.One of SEK, EUR, USD, GBP, NOK, DKK, BAM, CHF, PLN or CZK. |
invoice_emailstring or null | Where invoices to the client are emailed. |
vat_treatmentstring | How VAT applies to the client: Swedish VAT, EU reverse charge, or a buyer outside the EU.One of domestic, eu_reverse_charge or outside_eu. |
vat_numberstring or null | The client's VAT number. |
is_activeboolean | False once the client has been deactivated. |
created_atdate-time | When the client was created. |
curl -G https://api.timeflov.com/v1/clients \
-H "Authorization: Bearer $TIMEFLOV_API_KEY" \
-d limit=100const api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const query = new URLSearchParams({
limit: "100",
});
const url = `${api}/clients?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const { data, next_cursor } = await res.json();import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
r = requests.get(
f"{API}/clients",
headers={"Authorization": f"Bearer {key}"},
params={
"limit": 100,
},
timeout=30,
)
r.raise_for_status()
page = r.json(){
"data": [
{
"id": "a3e91f04-6c2b-4f55-8d0e-7b19c4d2e6a8",
"name": "Nordvik Energi AB",
"org_number": "556677-8899",
"contact_name": "Karin Lund",
"contact_email": "karin.lund@nordvik.example",
"address": "Hamngatan 12",
"city": "Stockholm",
"zip_code": "111 47",
"country": "SE",
"payment_terms": 30,
"currency": "SEK",
"invoice_email": "invoices@nordvik.example",
"vat_treatment": "domestic",
"vat_number": "SE556677889901",
"is_active": true,
"created_at": "2025-11-04T13:40:51.006217+00:00"
}
],
"next_cursor": null
}List time entries#
/v1/time_entriesHours reported on your projects, by your team and by subcontractors. Hours appear as soon as they are saved, before the week is submitted or approved.
Needs a key with read:time_entries
Query parameters
| Parameter | Description |
|---|---|
limitinteger | Rows per page, 1 to 200. 100 when left out. |
cursoruuid | The next_cursor from the previous page. |
updated_sincedate-time | Only time entries changed at or after this time. |
Fields
| Field | Description |
|---|---|
iduuid | The entry's id. |
timesheet_iduuid or null | The weekly timesheet the hours belong to. Null for hours bought from a subcontractor. |
user_iduuid or null | The person who reported the hours. Null for hours bought from a subcontractor. |
project_iduuid | The project, an id from projects. |
vendor_iduuid or null | The subcontractor the hours were bought from. Null for your own team. |
datedate | The day the hours were worked. |
hoursnumber | Hours as a decimal: 7.5 is seven and a half hours. |
billableboolean | Whether the hours are billed to the client. |
descriptionstring or null | What was done. |
taskstring or null | The task the hours were reported on, when one was chosen. |
invoiced_line_iduuid or null | The customer invoice line that billed these hours. Null until they are invoiced. |
created_atdate-time | When the entry was created. |
updated_atdate-time | When the entry last changed. updated_since compares with this. |
curl -G https://api.timeflov.com/v1/time_entries \
-H "Authorization: Bearer $TIMEFLOV_API_KEY" \
-d limit=100 \
-d updated_since=2026-10-01T00:00:00Zconst api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const query = new URLSearchParams({
limit: "100",
updated_since: "2026-10-01T00:00:00Z",
});
const url = `${api}/time_entries?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const { data, next_cursor } = await res.json();import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
r = requests.get(
f"{API}/time_entries",
headers={"Authorization": f"Bearer {key}"},
params={
"limit": 100,
"updated_since": "2026-10-01T00:00:00Z",
},
timeout=30,
)
r.raise_for_status()
page = r.json(){
"data": [
{
"id": "e41a7c92-3b5d-4f08-96c1-8a2d4e6f0b73",
"timesheet_id": "c7d2e5b1-9f34-4a60-b8e2-1d5f7a3c9e04",
"user_id": "2b8f6d13-0e7a-4c95-a1d4-6f3e9b2c8a57",
"project_id": "5f0c2a9e-41d7-4b8e-9a51-0d6c3e2f7b10",
"vendor_id": null,
"date": "2026-10-12",
"hours": 7.5,
"billable": true,
"description": "Pipeline tests for the meter feed",
"task": "Development",
"invoiced_line_id": null,
"created_at": "2026-10-12T16:05:33.719402+00:00",
"updated_at": "2026-10-12T16:05:33.719402+00:00"
}
],
"next_cursor": null
}List customer invoices#
/v1/customer_invoicesInvoices and credit notes to your clients. total_amount includes VAT.
Needs a key with read:customer_invoices
Query parameters
| Parameter | Description |
|---|---|
limitinteger | Rows per page, 1 to 200. 100 when left out. |
cursoruuid | The next_cursor from the previous page. |
updated_sincedate-time | Only customer invoices changed at or after this time. |
Fields
| Field | Description |
|---|---|
iduuid | The invoice's id. |
client_iduuid | The client invoiced, an id from clients. |
invoice_numberstring | The invoice number. |
statusstring | Where the invoice is. An unpaid invoice past its due date usually still reads sent, so compare due_date with today to find overdue invoices.One of draft, review, sent, overdue, paid, credited or voided. |
invoice_datedate | The invoice date. |
due_datedate | The day payment is due. |
total_amountnumber | The total including VAT, in the invoice's currency. Negative on a credit note. |
vat_amountnumber | The VAT included in total_amount. |
currencystring | The invoice's currency.One of SEK, EUR, USD, GBP, NOK, DKK, BAM, CHF, PLN or CZK. |
exchange_ratenumber | Multiply an amount by this to get it in your company's base currency. 1 when the invoice is in the base currency. |
payment_termsnumber | Days from the invoice date to the due date. |
sent_atdate-time or null | When the invoice was issued and booked. Whether the email or e-invoice reached the client is in delivery_status. |
paid_atdate-time or null | When the invoice was recorded as paid. |
credited_invoice_iduuid or null | On a credit note, the invoice it credits. |
vat_treatmentstring | How VAT was applied, fixed when the invoice was issued.One of domestic, eu_reverse_charge or outside_eu. |
delivery_statusstring | Where the email or e-invoice delivery is.One of unsent, queued, sending, delivered, pending_provider or failed. |
created_atdate-time | When the invoice was created. |
updated_atdate-time | When the invoice last changed. updated_since compares with this. |
curl -G https://api.timeflov.com/v1/customer_invoices \
-H "Authorization: Bearer $TIMEFLOV_API_KEY" \
-d limit=100 \
-d updated_since=2026-10-01T00:00:00Zconst api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const query = new URLSearchParams({
limit: "100",
updated_since: "2026-10-01T00:00:00Z",
});
const url = `${api}/customer_invoices?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const { data, next_cursor } = await res.json();import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
r = requests.get(
f"{API}/customer_invoices",
headers={"Authorization": f"Bearer {key}"},
params={
"limit": 100,
"updated_since": "2026-10-01T00:00:00Z",
},
timeout=30,
)
r.raise_for_status()
page = r.json(){
"data": [
{
"id": "9d3b1f68-27c4-4e9a-b05d-3c8e1a7f6d42",
"client_id": "a3e91f04-6c2b-4f55-8d0e-7b19c4d2e6a8",
"invoice_number": "INV-2026-0142",
"status": "sent",
"invoice_date": "2026-10-01",
"due_date": "2026-10-31",
"total_amount": 156250,
"vat_amount": 31250,
"currency": "SEK",
"exchange_rate": 1,
"payment_terms": 30,
"sent_at": "2026-10-01T08:02:11.204918+00:00",
"paid_at": null,
"credited_invoice_id": null,
"vat_treatment": "domestic",
"delivery_status": "delivered",
"created_at": "2026-09-30T15:48:20.118352+00:00",
"updated_at": "2026-10-01T08:02:11.204918+00:00"
}
],
"next_cursor": null
}List expense reports#
/v1/expensesExpense reports with their lines. Amounts include VAT.
Needs a key with read:expenses
Query parameters
| Parameter | Description |
|---|---|
limitinteger | Rows per page, 1 to 200. 100 when left out. |
cursoruuid | The next_cursor from the previous page. |
updated_sincedate-time | Only expense reports changed at or after this time. |
Fields
| Field | Description |
|---|---|
iduuid | The report's id. |
user_iduuid | The person the report belongs to. |
titlestring | The report's title. |
statusstring | Where the report is. paid means the money has been paid out.One of draft, submitted, approved, rejected or paid. |
total_amountnumber | The sum of the lines' amount, including VAT, added without converting between currencies. For a report with lines in more than one currency, total the lines by their own currency instead. |
currencystring | The currency set on the report. Each line has its own currency, which can differ.One of SEK, EUR, USD, GBP, NOK, DKK, BAM, CHF, PLN or CZK. |
submitted_atdate-time or null | When the report was submitted for approval. |
approved_atdate-time or null | When the report was approved. |
paid_atdate-time or null | When the money was paid out. |
created_atdate-time | When the report was created. |
updated_atdate-time | When the report last changed. updated_since compares with this. |
Fields on each line
Each report carries every one of its lines in lines, in id order.
| Field | Description |
|---|---|
iduuid | The line's id. |
project_iduuid | The project the cost belongs to. |
expense_typestring | The kind of expense.One of receipt, mileage, per_diem, credit_card or wellness. |
descriptionstring | What the expense was for. |
amountnumber | The amount including VAT, in the line's currency. |
vat_amountnumber | The deductible VAT included in amount. Always 0 on mileage and per diem lines. |
currencystring | The line's currency.One of SEK, EUR, USD, GBP, NOK, DKK, BAM, CHF, PLN or CZK. |
datedate | The day of the expense. |
is_billableboolean | Whether the cost is billed on to the client. |
is_wellnessboolean | Whether the line counts against the wellness allowance. |
mileage_kmnumber or null | Kilometres driven, on a mileage line. |
invoiced_line_iduuid or null | The customer invoice line that billed the cost on. Null until it is billed. |
curl -G https://api.timeflov.com/v1/expenses \
-H "Authorization: Bearer $TIMEFLOV_API_KEY" \
-d limit=100 \
-d updated_since=2026-10-01T00:00:00Zconst api = "https://api.timeflov.com/v1";
const key = process.env.TIMEFLOV_API_KEY;
const query = new URLSearchParams({
limit: "100",
updated_since: "2026-10-01T00:00:00Z",
});
const url = `${api}/expenses?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const { data, next_cursor } = await res.json();import os
import requests
API = "https://api.timeflov.com/v1"
key = os.environ["TIMEFLOV_API_KEY"]
r = requests.get(
f"{API}/expenses",
headers={"Authorization": f"Bearer {key}"},
params={
"limit": 100,
"updated_since": "2026-10-01T00:00:00Z",
},
timeout=30,
)
r.raise_for_status()
page = r.json(){
"data": [
{
"id": "0a6e4c2f-8b1d-4f39-a7e5-2c9d1b3f6e84",
"user_id": "2b8f6d13-0e7a-4c95-a1d4-6f3e9b2c8a57",
"title": "Client workshop in Gothenburg",
"status": "approved",
"total_amount": 1840,
"currency": "SEK",
"submitted_at": "2026-10-09T07:31:45.660128+00:00",
"approved_at": "2026-10-10T10:12:03.090551+00:00",
"paid_at": null,
"created_at": "2026-10-08T19:20:14.538770+00:00",
"updated_at": "2026-10-10T10:12:03.090551+00:00",
"lines": [
{
"id": "f2c9b7e4-1a6d-4e05-b8c3-7d4a2f9e1b65",
"project_id": "5f0c2a9e-41d7-4b8e-9a51-0d6c3e2f7b10",
"expense_type": "receipt",
"description": "Train Stockholm to Gothenburg",
"amount": 1840,
"vat_amount": 104.15,
"currency": "SEK",
"date": "2026-10-08",
"is_billable": true,
"is_wellness": false,
"mileage_km": null,
"invoiced_line_id": null
}
]
}
],
"next_cursor": null
}