Examples
Export a month of worklogs, log time from a script, total hours for payroll, keep another system in step, and submit and approve timesheets.
Each example reads the key from the LOGQUILL_KEY environment variable. Make a key with the scopes it
names. API keys
Export a month of worklogs to CSV
Scope: worklogs:read
Follows next until the last page, and waits when asked to (429).
Uses jq to turn each page into CSV rows. --retry waits as long as
Retry-After says.
url="https://api.logquill.com/v1/worklogs?from=2026-10-01&to=2026-10-31&limit=1000"
echo "date,person,issue,hours,billable hours,description" > worklogs.csv
while [ -n "$url" ]; do
page=$(curl -sf --retry 5 -H "Authorization: Bearer $LOGQUILL_KEY" "$url") || exit 1
echo "$page" | jq -r '.results[] | [.date, .author.displayName, (.issue.id // ""),
(.timeSpentSeconds / 3600), (.billableSeconds / 3600), (.description // "")] | @csv' >> worklogs.csv
url=$(echo "$page" | jq -r '.metadata.next // empty')
done$headers = @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
$url = "https://api.logquill.com/v1/worklogs?from=2026-10-01&to=2026-10-31&limit=1000"
$rows = while ($url) {
try {
$page = Invoke-RestMethod $url -Headers $headers
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 429) { throw }
Start-Sleep -Seconds 60 # the limits count by the minute
continue
}
foreach ($w in $page.results) {
[pscustomobject]@{
date = $w.date
person = $w.author.displayName
issue = $w.issue.id
hours = $w.timeSpentSeconds / 3600
billableHours = $w.billableSeconds / 3600
description = $w.description
}
}
$url = $page.metadata.next
}
$rows | Export-Csv worklogs.csv -NoTypeInformation -Encoding utf8import csv, os, time, requests
API = "https://api.logquill.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['LOGQUILL_KEY']}"
def get(url, **params):
while True:
answer = session.get(url, params=params)
if answer.status_code == 429:
time.sleep(int(answer.headers.get("Retry-After", "5")))
continue
answer.raise_for_status()
return answer.json()
url, params = f"{API}/worklogs", {"from": "2026-10-01", "to": "2026-10-31", "limit": 1000}
with open("worklogs.csv", "w", newline="") as file:
out = csv.writer(file)
out.writerow(["date", "person", "issue", "hours", "billable hours", "description"])
while url:
page = get(url, **params)
for w in page["results"]:
out.writerow([
w["date"],
w["author"]["displayName"],
w["issue"]["id"] if w["issue"] else "",
w["timeSpentSeconds"] / 3600,
w["billableSeconds"] / 3600,
w["description"] or "",
])
url, params = page["metadata"]["next"], {} # next already holds the parametersimport { writeFile } from "node:fs/promises";
const headers = { Authorization: `Bearer ${process.env.LOGQUILL_KEY}` };
async function get(url) {
for (;;) {
const answer = await fetch(url, { headers });
if (answer.status === 429) {
const wait = Number(answer.headers.get("Retry-After") ?? 5);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
if (!answer.ok) throw new Error(`${answer.status}: ${await answer.text()}`);
return answer.json();
}
}
const cell = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const lines = ["date,person,issue,hours,billable hours,description"];
let url = "https://api.logquill.com/v1/worklogs?from=2026-10-01&to=2026-10-31&limit=1000";
while (url) {
const page = await get(url);
for (const w of page.results) {
lines.push(
[w.date, w.author.displayName, w.issue?.id, w.timeSpentSeconds / 3600, w.billableSeconds / 3600, w.description]
.map(cell)
.join(","),
);
}
url = page.metadata.next;
}
await writeFile("worklogs.csv", lines.join("\n"));To export only one person's time, add accountId=<their Jira account id>, or accountId=me for
your own. Others' worklogs only come back if your role lets you see them in LogQuill.
Log time from a script
Scope: worklogs:write
Logs 1h 30m on ABC-123 today, as you.
curl -X POST https://api.logquill.com/v1/worklogs \
-H "Authorization: Bearer $LOGQUILL_KEY" \
-H "Content-Type: application/json" \
-d "{\"issue\": {\"key\": \"ABC-123\"}, \"date\": \"$(date +%F)\", \"timeSpentSeconds\": 5400, \"description\": \"Release checks\"}"$body = @{
issue = @{ key = "ABC-123" }
date = Get-Date -Format "yyyy-MM-dd"
timeSpentSeconds = 5400
description = "Release checks"
} | ConvertTo-Json
try {
$worklog = Invoke-RestMethod https://api.logquill.com/v1/worklogs -Method Post `
-Headers @{ Authorization = "Bearer $env:LOGQUILL_KEY" } -ContentType "application/json" -Body $body
} catch {
# Why it was refused: a locked timesheet, a full day, a missing field...
# Windows PowerShell 5.1 leaves the answer in the response itself.
$answer = $_.ErrorDetails.Message
if (-not $answer) { $answer = [IO.StreamReader]::new($_.Exception.Response.GetResponseStream()).ReadToEnd() }
throw ($answer | ConvertFrom-Json).message
}
"Logged worklog $($worklog.id)"import datetime, os, requests
answer = requests.post(
"https://api.logquill.com/v1/worklogs",
headers={"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"},
json={
"issue": {"key": "ABC-123"},
"date": datetime.date.today().isoformat(),
"timeSpentSeconds": 5400,
"description": "Release checks",
},
)
if answer.status_code != 201:
print(answer.json()["message"]) # a locked timesheet, a full day, a missing field...
answer.raise_for_status()
print("Logged worklog", answer.json()["id"])const answer = await fetch("https://api.logquill.com/v1/worklogs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOGQUILL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
issue: { key: "ABC-123" },
date: new Date().toLocaleDateString("en-CA"), // today, YYYY-MM-DD
timeSpentSeconds: 5400,
description: "Release checks",
}),
});
const worklog = await answer.json();
if (answer.status !== 201) throw new Error(worklog.message); // a locked timesheet, a full day...
console.log("Logged worklog", worklog.id);The answer is the new worklog. Your site's rules apply as in LogQuill: if your site requires a work
type, add "workType": {"id": 3}; custom fields go in "fields", by field id. To change it later:
PATCH /worklogs/{id} with only what changes; to remove it: DELETE /worklogs/{id}.
Hours per person for payroll
Scope: time:read
Totals for a month, one row per person, over everyone you may see.
curl -H "Authorization: Bearer $LOGQUILL_KEY" \
"https://api.logquill.com/v1/time?from=2026-10-01&to=2026-10-31&groupBy=person&type=worklogs"$totals = Invoke-RestMethod "https://api.logquill.com/v1/time?from=2026-10-01&to=2026-10-31&groupBy=person&type=worklogs" `
-Headers @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
$totals.results | Select-Object @{ n = "person"; e = { $_.person.displayName } },
@{ n = "hours"; e = { $_.loggedSeconds / 3600 } }, @{ n = "billableHours"; e = { $_.billableSeconds / 3600 } }import os, requests
totals = requests.get(
"https://api.logquill.com/v1/time",
headers={"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"},
params={"from": "2026-10-01", "to": "2026-10-31", "groupBy": "person", "type": "worklogs"},
).json()
for row in totals["results"]:
print(row["person"]["displayName"], row["loggedSeconds"] / 3600, row["billableSeconds"] / 3600)const params = new URLSearchParams({ from: "2026-10-01", to: "2026-10-31", groupBy: "person", type: "worklogs" });
const answer = await fetch(`https://api.logquill.com/v1/time?${params}`, {
headers: { Authorization: `Bearer ${process.env.LOGQUILL_KEY}` },
});
const totals = await answer.json();
for (const row of totals.results) {
console.log(row.person.displayName, row.loggedSeconds / 3600, row.billableSeconds / 3600);
}The answer:
{
"results": [
{
"person": { "accountId": "712020:4d42...", "displayName": "Alex Martin" },
"loggedSeconds": 576000,
"billableSeconds": 432000,
"plannedSeconds": 0
},
{
"person": { "accountId": "557058:9a1c...", "displayName": "Sam Lee" },
"loggedSeconds": 540000,
"billableSeconds": 360000,
"plannedSeconds": 0
}
],
"metadata": { "count": 2, "limit": 100, "next": null }
}groupBy takes several, comma-separated: person,day for a row per person per day,
account,project for billing. With day among them, rows come day by day, in date order, so a
long period pages through quickly. With more than a page of rows, follow next as in the first
example.
Ask for time off
Scope: absences:write
Asks for two days of paid leave as you, with a note for whoever approves it. It follows LogQuill's
rules: the days must be working days, free of your other absences, and within your balance. The
answer is the absence, pending until it's approved (or approved at once for a type that needs no
approval), and your approvers get the usual email. Sick leave is the same call with its type's id,
from /absence-types; a sick note is added in LogQuill afterwards.
curl -X POST -H "Authorization: Bearer $LOGQUILL_KEY" -H "Content-Type: application/json" \
-d '{"typeId": 1, "startDate": "2026-12-21", "endDate": "2026-12-22", "note": "Family trip"}' \
"https://api.logquill.com/v1/absences"$body = @{ typeId = 1; startDate = "2026-12-21"; endDate = "2026-12-22"; note = "Family trip" } | ConvertTo-Json
Invoke-RestMethod "https://api.logquill.com/v1/absences" -Method Post -Body $body `
-ContentType "application/json" -Headers @{ Authorization = "Bearer $env:LOGQUILL_KEY" }import os, requests
absence = requests.post(
"https://api.logquill.com/v1/absences",
headers={"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"},
json={"typeId": 1, "startDate": "2026-12-21", "endDate": "2026-12-22", "note": "Family trip"},
).json()
print(absence["status"], absence["workingDays"])const answer = await fetch("https://api.logquill.com/v1/absences", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.LOGQUILL_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ typeId: 1, startDate: "2026-12-21", endDate: "2026-12-22", note: "Family trip" }),
});
const absence = await answer.json();
console.log(absence.status, absence.workingDays);Half days: startPart: "afternoon" starts the first day at noon, endPart: "morning" ends the last
at noon, and dayPart takes only the morning or afternoon of every day. To withdraw it, or ask to
cancel it once approved, POST /absences/{id}/cancel.
Days off for payroll
Scope: absences:read
Approved absences touching a month, each with its working days (half days as half). Payroll counts
approved and cancelling, which still counts until its cancellation is accepted.
curl -H "Authorization: Bearer $LOGQUILL_KEY" \
"https://api.logquill.com/v1/absences?from=2026-10-01&to=2026-10-31&status=approved"$absences = Invoke-RestMethod "https://api.logquill.com/v1/absences?from=2026-10-01&to=2026-10-31&status=approved" `
-Headers @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
$absences.results | Select-Object @{ n = "person"; e = { $_.person.displayName } },
@{ n = "type"; e = { $_.type.name } }, startDate, endDate, workingDaysimport os, requests
absences = requests.get(
"https://api.logquill.com/v1/absences",
headers={"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"},
params={"from": "2026-10-01", "to": "2026-10-31", "status": "approved"},
).json()
for absence in absences["results"]:
kind = absence["type"]["name"] if absence["type"] else "Absent"
print(absence["person"]["displayName"], kind, absence["startDate"], absence["endDate"], absence["workingDays"])const params = new URLSearchParams({ from: "2026-10-01", to: "2026-10-31", status: "approved" });
const answer = await fetch(`https://api.logquill.com/v1/absences?${params}`, {
headers: { Authorization: `Bearer ${process.env.LOGQUILL_KEY}` },
});
const absences = await answer.json();
for (const absence of absences.results) {
console.log(absence.person.displayName, absence.type?.name ?? "Absent", absence.startDate, absence.endDate, absence.workingDays);
}The answer:
{
"results": [
{
"id": 4521,
"person": { "accountId": "712020:4d42...", "displayName": "Alex Martin" },
"type": { "id": 1, "name": "Paid leave" },
"startDate": "2026-10-19",
"endDate": "2026-10-23",
"startPart": "full",
"endPart": "morning",
"dayPart": "full",
"workingDays": 4.5,
"status": "approved",
"source": "logquill",
"note": null,
"decidedAt": "2026-10-12T09:30:00.000Z",
"createdAt": "2026-10-09T08:00:00.000Z",
"updatedAt": "2026-10-12T09:30:00.000Z"
}
],
"metadata": { "count": 1, "limit": 100, "next": null }
}type is null for someone whose type you may not know. GET /absence-types lists the types, and
each timesheet day says its absence (absence, with its part of the day). To keep an HR system in
step, add updatedFrom: only the absences changed since, cancellations included.
GET /balances gives each person's leave balances, as the app shows them today: per absence type
with an allowance plan, the days they may still ask for this leave year (available), what was
earned, taken and carried over, approved and pending days, and carried days about to be lost. It
lists the people whose absence types you may know: yourself, the teams you lead, the people whose
absences you approve, and everyone for HR. accountId=me gives your own; teamId one team's.
Keep another system in step
Scope: worklogs:read
Ask for what changed since your last run with updatedFrom, and keep the moment you asked as the
next run's starting point.
curl -H "Authorization: Bearer $LOGQUILL_KEY" \
"https://api.logquill.com/v1/worklogs?from=2026-01-01&to=2026-12-31&updatedFrom=2026-10-14T06:00:00Z"$since = "2026-10-14T06:00:00Z" # when the last run asked
$changed = Invoke-RestMethod "https://api.logquill.com/v1/worklogs?from=2026-01-01&to=2026-12-31&updatedFrom=$since" `
-Headers @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
"$($changed.metadata.count) worklogs changed"import os, requests
since = "2026-10-14T06:00:00Z" # when the last run asked
changed = requests.get(
"https://api.logquill.com/v1/worklogs",
headers={"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"},
params={"from": "2026-01-01", "to": "2026-12-31", "updatedFrom": since},
).json()
print(changed["metadata"]["count"], "worklogs changed")const since = "2026-10-14T06:00:00Z"; // when the last run asked
const params = new URLSearchParams({ from: "2026-01-01", to: "2026-12-31", updatedFrom: since });
const answer = await fetch(`https://api.logquill.com/v1/worklogs?${params}`, {
headers: { Authorization: `Bearer ${process.env.LOGQUILL_KEY}` },
});
const changed = await answer.json();
console.log(changed.metadata.count, "worklogs changed");These show the first page only: follow next as in the first example. Deleted worklogs don't come
back in a list: compare the ids you hold for the period with the ones returned to find them.
Submit your timesheet
Scopes: timesheets:read to look first, timesheets:write to submit
Reads the timesheet for the week of 9 October, then submits it to the first team it can go to.
sheet=$(curl -sf -H "Authorization: Bearer $LOGQUILL_KEY" \
"https://api.logquill.com/v1/timesheets?date=2026-10-09")
echo "$sheet" | jq '{status, canSubmit, blockReason, submitTo}'
team=$(echo "$sheet" | jq -r '.submitTo[0].id')
curl -X POST https://api.logquill.com/v1/timesheets/submit \
-H "Authorization: Bearer $LOGQUILL_KEY" \
-H "Content-Type: application/json" \
-d "{\"date\": \"2026-10-09\", \"teamId\": \"$team\"}"$ErrorActionPreference = "Stop" # stop at the first refusal
$headers = @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
$sheet = Invoke-RestMethod "https://api.logquill.com/v1/timesheets?date=2026-10-09" -Headers $headers
if (-not $sheet.canSubmit) { throw "Can't submit: $($sheet.blockReason)" }
$body = @{ date = "2026-10-09"; teamId = $sheet.submitTo[0].id } | ConvertTo-Json
$submitted = Invoke-RestMethod https://api.logquill.com/v1/timesheets/submit -Method Post `
-Headers $headers -ContentType "application/json" -Body $body
"Now $($submitted.status)"import os, requests
API = "https://api.logquill.com/v1"
headers = {"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"}
sheet = requests.get(f"{API}/timesheets", headers=headers, params={"date": "2026-10-09"}).json()
if not sheet["canSubmit"]:
raise SystemExit(f"Can't submit: {sheet['blockReason']}")
submitted = requests.post(
f"{API}/timesheets/submit",
headers=headers,
json={"date": "2026-10-09", "teamId": sheet["submitTo"][0]["id"]},
).json()
print("Now", submitted["status"])const API = "https://api.logquill.com/v1";
const headers = { Authorization: `Bearer ${process.env.LOGQUILL_KEY}` };
const sheet = await (await fetch(`${API}/timesheets?date=2026-10-09`, { headers })).json();
if (!sheet.canSubmit) throw new Error(`Can't submit: ${sheet.blockReason}`);
const answer = await fetch(`${API}/timesheets/submit`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ date: "2026-10-09", teamId: sheet.submitTo[0].id }),
});
console.log("Now", (await answer.json()).status);blockReason says why it can't go yet: periodNotEnded, incomplete (time is missing), noTeams,
onlyLeader, alreadySubmitted, approved or noTimeTracking.
Approve a team's submitted timesheets
Scopes: timesheets:read and timesheets:write, as the team's leader or someone who reviews
every timesheet
Approves each timesheet of the period that's waiting and has no missing time. Team ids come from
GET /teams.
team="3f2b6c1e-8d4a-4f5b-9c7e-2a1d0b9e8f7a"
curl -sf -H "Authorization: Bearer $LOGQUILL_KEY" \
"https://api.logquill.com/v1/teams/$team/timesheets?date=2026-10-09" |
jq -r '.results[] | select(.status == "submitted" and .missingSeconds == 0) | .timesheetId' |
while read -r id; do
curl -sf -X POST "https://api.logquill.com/v1/timesheets/$id/approve" \
-H "Authorization: Bearer $LOGQUILL_KEY" -H "Content-Type: application/json" -d '{}' > /dev/null
echo "Approved $id"
done$ErrorActionPreference = "Stop" # stop at the first refusal
$headers = @{ Authorization = "Bearer $env:LOGQUILL_KEY" }
$team = "3f2b6c1e-8d4a-4f5b-9c7e-2a1d0b9e8f7a"
$period = Invoke-RestMethod "https://api.logquill.com/v1/teams/$team/timesheets?date=2026-10-09" -Headers $headers
foreach ($row in $period.results | Where-Object { $_.status -eq "submitted" -and $_.missingSeconds -eq 0 }) {
Invoke-RestMethod "https://api.logquill.com/v1/timesheets/$($row.timesheetId)/approve" -Method Post `
-Headers $headers -ContentType "application/json" -Body "{}" | Out-Null
"Approved $($row.person.displayName)"
}import os, requests
API = "https://api.logquill.com/v1"
headers = {"Authorization": f"Bearer {os.environ['LOGQUILL_KEY']}"}
team = "3f2b6c1e-8d4a-4f5b-9c7e-2a1d0b9e8f7a"
period = requests.get(f"{API}/teams/{team}/timesheets", headers=headers, params={"date": "2026-10-09"}).json()
for row in period["results"]:
if row["status"] == "submitted" and row["missingSeconds"] == 0:
requests.post(f"{API}/timesheets/{row['timesheetId']}/approve", headers=headers, json={}).raise_for_status()
print("Approved", row["person"]["displayName"])const API = "https://api.logquill.com/v1";
const headers = { Authorization: `Bearer ${process.env.LOGQUILL_KEY}`, "Content-Type": "application/json" };
const team = "3f2b6c1e-8d4a-4f5b-9c7e-2a1d0b9e8f7a";
const period = await (await fetch(`${API}/teams/${team}/timesheets?date=2026-10-09`, { headers })).json();
for (const row of period.results) {
if (row.status !== "submitted" || row.missingSeconds > 0) continue;
await fetch(`${API}/timesheets/${row.timesheetId}/approve`, { method: "POST", headers, body: "{}" });
console.log("Approved", row.person.displayName);
}To send one back instead: POST /timesheets/{id}/reject with {"note": "Missing Friday afternoon"}.
Its owner gets the note by email, as in LogQuill.