Ownware
Home›Clockora›API
Clockora · API

Clockora API, as shipped in the download

Clockora REST API & Webhooks (v2.0)

Clockora ships a JSON REST API and signed webhooks so time can be logged from something other than this browser — a shop-floor tablet, a door badge reader, a script that files yesterday's hours — and so period totals can be pulled into payroll on a schedule instead of exported by hand.

Clockora records hours, never money. There is no rate, no amount, no price anywhere in the API, because there is none in the product.

Authentication

Create a key in API & Webhooks. A key belongs to a user and can do exactly what that user can do in the app. Send it on every call:

Authorization: Bearer clk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

/api/* is CSRF-exempt by design — the key is the credential, and browsers never send it implicitly.

Endpoints

Method & pathWhat it does
GET /api/pingVerify a key
GET /api/entriesNewest 50. ?employee_id=, ?project_id=, ?status=, ?from=, ?to=
POST /api/entriesLog time — a clock span or a manual duration
GET /api/entries/{id}One entry
POST /api/entries/{id}/submitDraft (or rejected) → submitted
POST /api/entries/{id}/approveSubmitted → approved (stamps approver + time)
POST /api/entries/{id}/rejectSubmitted → rejected
GET /api/employeesRoster with each person's weekly overtime threshold
GET /api/projectsProjects (labels for grouping hours)
GET /api/timesheetPeriod totals by employee and project. ?from=, ?to=
GET /api/openapi.jsonOpenAPI 3 spec (no auth)
GET /healthz{"ok":true,"app":"clockora","version":"2.0.0"} (no auth)

Log time

A clock entry carries start and end times; a manual entry carries a duration. Omit method and Clockora infers it from which fields you sent.

curl -X POST https://your-install/api/entries \
  -H "Authorization: Bearer clk_..." -H "Content-Type: application/json" \
  -d '{"employee_id": 3, "entry_date": "2026-03-02",
       "start_time": "09:00", "end_time": "17:30", "note": "Line 2 changeover"}'

curl -X POST https://your-install/api/entries \
  -H "Authorization: Bearer clk_..." -H "Content-Type: application/json" \
  -d '{"employee_id": 3, "entry_date": "2026-03-02", "duration": "7:30"}'

duration accepts 7:30, 450, or 7.5. minutes works as an alias.

Durations come back as integer minutes plus Clockora's own formatting (Timesheet::fmtDuration, the same text the screen shows), so a client never has to turn 450 into "7h 30m" and get it subtly different:

{"entry": {"id": 91, "minutes": 450, "duration": "7h 30m",
           "start_time": "09:00", "end_time": "16:30", "crosses_midnight": false,
           "method": "clock", "status": "draft"}}

A span that runs past midnight is a real span, not a negative one — 22:00→06:00 is 480 minutes and crosses_midnight is true.

The rules, and why the API cannot dodge them

Validation is not re-implemented for the API. A create goes through _entry_from_input() — the exact function the browser form calls — so all four rules apply identically:

  • an employee must exist and be active, and the date must be real;
  • a single entry cannot exceed 24 hours;
  • a clock entry cannot overlap another clock entry for the same person (checked across a ±1-day window, because a ≤24h entry can spill one day either way);
  • a day holds 24 hours. Entries that are each individually legal can still sum to an impossible day — ten 8-hour manual entries is 80 hours on one date. The day-total ceiling refuses the one that would cross 1440 minutes. Rejected entries don't count toward it.

A refusal returns 422 with the sentence the form would have shown:

{"error": "unprocessable",
 "detail": "That would put Ada Reeve at 24h 1m on Mar 2, 2026 — a day holds 24 hours.
            Existing entries that day total 24h."}

The status transitions are guarded the same way, by _entry_submit_refusal() and _entry_decision_refusal(). Approving something that is not submitted returns 409:

{"error": "conflict", "detail": "Only a submitted entry can be approved."}

Period totals

/api/timesheet calls _period_from_request() + _timesheet_data() — the same pair behind the HTML timesheet — so the API, the screen and the PDF cannot disagree. Overtime is a flag derived from each person's weekly threshold, never a pay figure:

{"period": {"from": "2026-03-02", "to": "2026-03-08", "week_start_dow": 1},
 "total_minutes": 2280, "total_duration": "38h",
 "by_employee": [{"employee_id": 3, "name": "Ada Reeve", "minutes": 2280,
                  "duration": "38h", "weekly_threshold_minutes": 2250,
                  "overtime_weeks": 1, "status_counts": {"draft": 1, "submitted": 4, ...}}],
 "by_project": [...]}

Webhooks

Add receiver URLs in API & Webhooks. Events:

  • entry.created — time was logged: through the form, POST /api/entries, or a stopped timer
  • entry.submitted — time went to the approver (fires for single and bulk period submits)
  • entry.approved — an approver accepted it
  • entry.rejected — an approver sent it back

Every delivery is signed:

X-Clockora-Event: entry.submitted
X-Clockora-Signature: sha256=<hmac_sha256(body, your endpoint secret)>

Verify by recomputing the HMAC over the raw body with the endpoint's secret. Deliveries are logged (newest 200 per endpoint) with the response code, so "did it fire?" is answerable from the admin page.

Delivery is best-effort with a short timeout: a slow receiver must never block someone clocking out.

Email notifications

Bring your own SMTP (Settings → Email notifications). Leave the host blank and Clockora sends nothing — no external service is contacted. Two toggles:

  • Time submitted for approval (on by default) — one message per submit, and a single summary for a bulk period submit rather than one per entry
  • Entry approved or rejected (off by default — the approver already knows)

Documented limits

  • Entries are create-and-read over the API, plus the three status transitions. Editing and deleting stay in the UI: an approved entry is an approver-stamped record and deleting it must not silently change a period total.
  • Employees and projects are read-only over the API. Add people via the UI or the CSV import.
  • The overtime threshold is a flag, not a rate. Clockora does not know what an hour is worth and will not pretend to.

Own It 3.0

Period locks — the rule that governs every door

A locked period is a closed pay period. Nothing dated inside one can be created, submitted, approved, rejected or deleted. Lock a period from Pay periods once you have approved everything you intend to pay.

The lock is a single function in the application (_ck_lock_refusal()), and every entry rule in the product routes through it. That is why the browser form, POST /api/entries, the API's status transitions and the MCP tools all refuse with the same sentence:

Jun 3, 2026 is inside a locked period (Jun 1, 2026 to Jun 7, 2026) — payroll.
Unlock the period to change it.
DoorWhat you get back
The appThe refusal as an error flash; nothing is written
POST /api/entries422 unprocessable, the sentence in detail
`POST /api/entries/{id}/submit\approve\reject`409 conflict, the sentence in detail
POST /mcpA tool error, isError: true, the sentence as the text

There is no second implementation of the rule to drift out of step, and the test suite asserts that the definition exists exactly once. Reading is never blocked: locking a period closes writing, not looking.

Unlocking is an administrator action and is written to the audit trail with who did it and which period — that is the event worth being able to find later.

MCP — the agent endpoint

Clockora speaks MCP (Model Context Protocol) at POST /mcp, authenticated with the same revocable API keys the REST API uses. Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE.

GET /mcp answers 405 with Allow: POST — deliberately before the API-key check. A client probing for MCP support with no credentials must learn that the transport is here and takes POST; answering 401 would tell it we have no MCP endpoint at all, which is false. The unauthenticated hint names no tools.

POST /mcp
Authorization: Bearer clk_...
Content-Type: application/json

A "timesheet" here is a view, not a row. Clockora stores time_entries; a timesheet is one employee over one period. Every tool takes from/to (defaulting to the current week, using your configured week start).

ToolWrites?What it does
list_timesheetsnoEveryone with time in a period: totals, status breakdown, lock state. Optional status filter.
timesheet_detailnoOne person's entries, totals by project, and the overtime flag. approved_only narrows it to signed-off time.
submit_timesheetYESSubmits that person's draft entries in the period for approval.
decide_timesheetYESApproves or rejects their submitted entries.
period_totalsnoTotals for everyone — the same roll-up the screens, the PDF and the payroll CSV use.

Connect it to an assistant

Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

The addresshttps://your-install/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP over streamable HTTP, stateless

Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:

claude mcp add --transport http clockora https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"

ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):

{
  "type": "mcp",
  "server_label": "clockora",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "clockora", "name": "Clockora", "url": "https://your-install/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}

**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.

A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Clockora implements the protocol, not an integration with a particular vendor.

What the endpoint refuses, and why

  • A locked period refuses the whole call, checked against the window before anything is read, so a closed period says "locked" rather than the less useful "nothing to submit".
  • Decide-once. An entry already approved or rejected is never re-decided. If any entry in the batch has been decided, the whole call is refused and nothing is written — a partial decision across a period is worse than none, because the agent will report success either way.
  • Roles apply exactly as they do in the browser. A key belonging to a viewer does not even see the writing tools.
  • Unknown arguments are refused by name, and out-of-enum values are refused, so `decision: "maybe"` never quietly becomes an approval.

Every MCP write lands in the audit trail alongside the browser and API writes.

Payroll export

GET /payroll.csv?mode=range&from=&to= — one row per employee for the period.

Two deliberate choices:

  • Approved entries only. Sending a payroll bureau a figure that includes unapproved drafts is the mistake this file exists to prevent.
  • Minutes and H:MM, never decimal hours. 7h05m is 7.0833…, and Clockora refuses decimal hours on input for exactly that reason — it never rounds a float into a minute. A bureau dividing by 60 applies its own rounding policy, which is the one it answers for.

There are no rates and no money anywhere in the file. Clockora does not know what an hour is worth.

Sign-off PDF

GET /employees/{id}/signoff.pdf?mode=range&from=&to= — the approved timesheet as a document you can file: approved entries only, the approver named against each, the period's lock state, and a signature block for both parties. Distinct from the 2.0 timesheet PDF, which is a working document and shows drafts.

Privacy

Privacy requests answers subject access (a full JSON export) and erasure. Erasure blanks the employee's name, email and job role, and the free-text note on their entries.

The hours are never deleted. A timesheet is the record that work was done and paid for; deleting the minutes would falsify the business's own books. Erasure removes who the hours belonged to, not that they existed. It is refused while any of that person's entries is still a draft or awaiting approval — an approver cannot judge a request from "[removed]".

Single sign-on

OIDC (Settings → Single sign-on). SSO does not create accounts: an address with no active Clockora user is refused rather than silently provisioned. Every failure — bad issuer, expired secret, unreachable provider — lands back on the local login page with a readable message, and password login keeps its 2FA gate. That is the break-glass route and it stays strong.

Scheduled backup

GET /backup/scheduled?t=<token>

A token-guarded URL for a cron job. No session — cron has no browser — so the token is the credential: minted once in Restore → Scheduled backup, shown once, stored only as a SHA-256 hash, and revoked by minting another. It writes data/backups/clockora-backup-<UTC>.json, keeps the newest N (configurable, default 14) and deletes older ones.

30 2 * * * curl -fsS "https://your-host/backup/scheduled?t=bkt_..." >/dev/null

Response: {"ok":true,"file":"clockora-backup-20260807-023000.json","pruned":1,"keep":14}. A missing, wrong or unset token gets 403 and writes nothing.

What is blanked in that file: password hashes, API-key hashes, webhook signing secrets, the SMTP password, the OIDC client secret, the backup token hash and 2FA seeds — the same list /backup.json uses, because the two must never drift. That makes this the copy you can hand to a bookkeeper or a contractor. /backup.sqlite is the whole install and is not redacted.

Restoring a redacted backup onto a working install keeps the live secrets: a [REDACTED] value never overwrites a real one.

Bulk actions

POST /entries/bulk        ids[]=1&ids[]=2&bulk_action=submit|approved|rejected|delete

Applies one action to many entries. It loops the same refusal and write functions the single-entry buttons use, inside one transaction — there is no mass-update SQL behind it. So a locked period, the decide-once rule and the approved-entry delete guard all apply per entry, and your role decides which actions you are offered at all (approved/rejected need entry.decide).

Refusals do not abort the batch: the entries that can move, move, and the response names how many were refused and the first reason. An all-or-nothing failure would only teach people to click one at a time.

← Back to Clockora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →