# 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.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot add time, and only an administrator's key can approve or reject it (`403 forbidden`).
A staff login cannot use the API: a key held by one is refused (`403 forbidden`), because the API reads and writes the whole team's records. Staff use My 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 timer (on the timesheet, on a person's own **My time** page, or on the kiosk — 3.2)
- `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.

| 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:

```sh
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):

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

**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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client 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:

```sh
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, the free-text note on their entries and on a timer still
running for them, and the name and email on their own "My time" sign-in, which is closed (4.1.1).

**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.


## Connect it to Zapier, Make or n8n

Clockora talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Clockora, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://clockora.yourcompany.com`.

### When something happens in Clockora: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Clockora under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Clockora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `entry.created` | Time was logged: through the form, , or a stopped timer (on the timesheet, on a person's own My time page, or on the kiosk — 3.2) |
| `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 carries `X-Clockora-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Clockora

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/entries`: Log time — a clock span or a manual duration
- `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

### Example recipes

- **entry.created → a row in Google Sheets:** webhook trigger on `entry.created` (Time was logged: through the form, , or a stopped timer (on the timesheet, on a person's own My time page, or on the kiosk — 3.2)), then the Google Sheets "Create row" action with the fields you want.
- **entry.submitted → a message in Slack or Teams:** webhook trigger on `entry.submitted` (Time went to the approver (fires for single *and* bulk period submits)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Clockora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/entries` (Log time — a clock span or a manual duration).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Clockora's own automated tests check its API.
