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 & path | What it does |
|---|---|
GET /api/ping | Verify a key |
GET /api/entries | Newest 50. ?employee_id=, ?project_id=, ?status=, ?from=, ?to= |
POST /api/entries | Log time — a clock span or a manual duration |
GET /api/entries/{id} | One entry |
POST /api/entries/{id}/submit | Draft (or rejected) → submitted |
POST /api/entries/{id}/approve | Submitted → approved (stamps approver + time) |
POST /api/entries/{id}/reject | Submitted → rejected |
GET /api/employees | Roster with each person's weekly overtime threshold |
GET /api/projects | Projects (labels for grouping hours) |
GET /api/timesheet | Period totals by employee and project. ?from=, ?to= |
GET /api/openapi.json | OpenAPI 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 timerentry.submitted— time went to the approver (fires for single and bulk period submits)entry.approved— an approver accepted itentry.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.
| Door | What you get back | ||
|---|---|---|---|
| The app | The refusal as an error flash; nothing is written | ||
POST /api/entries | 422 unprocessable, the sentence in detail | ||
| `POST /api/entries/{id}/submit\ | approve\ | reject` | 409 conflict, the sentence in detail |
POST /mcp | A 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).
| Tool | Writes? | What it does |
|---|---|---|
list_timesheets | no | Everyone with time in a period: totals, status breakdown, lock state. Optional status filter. |
timesheet_detail | no | One person's entries, totals by project, and the overtime flag. approved_only narrows it to signed-off time. |
submit_timesheet | YES | Submits that person's draft entries in the period for approval. |
decide_timesheet | YES | Approves or rejects their submitted entries. |
period_totals | no | Totals 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 address | https://your-install/mcp |
| The key | header Authorization: Bearer apk_xxxx |
| The transport | MCP 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.