LogQuill

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 utf8
import 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 parameters
import { 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, workingDays
import 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.