Timestamp
API v1 Product Start free trial

Timestamp API

Connect your own tools to Timestamp: a badge reader that clocks people in at the door, a payroll system that pulls the month's hours, an HR tool that keeps the roster in step. One REST API, one key per tool, and every call visible to the customer who issued the key.

Base URL

https://api.swissapp.io/v1

The same address is shown in the workspace under Timestamp → Settings → API access, next to the button that issues keys. Call it on its own and it returns a discovery document listing every endpoint your key can reach.

What you can do

If you are buildingYou will mostly use
A badge reader, turnstile or door terminal POST /clock/checkin, POST /clock/checkout — one call per tap, safe to retry
A payroll or accounting export GET /timesheets for computed hours, GET /entries for the raw intervals
An HR system that owns the roster POST/PATCH /employees, POST /absences
A staff planner or rota tool POST /entries to push confirmed shifts, GET /schedules to read the plan
A dashboard or "who is in the building" screen GET /clock/status with no arguments

Quickstart

1. Get a key. A workspace administrator creates it in Timestamp → Settings → API access, choosing read or write access and, optionally, a single location the key is allowed to touch. The key is shown once and never again.

The API access screen in Timestamp: the base URL with a copy button, a table of issued keys showing scope, last use and call count, and a log of recent calls.

2. Call it. Every request carries the key as a bearer token.

curl https://api.swissapp.io/v1/employees \
  -H "Authorization: Bearer ts_jHjHWS-_iFdL0LLBiUVFmUep4mT6IA58mS"

3. Teach it who is who. Register your own identifier — a badge UID, a payroll number — against a person, once. From then on you can address them by it and never store our ids.

curl -X PATCH "$BASE/employees/69cf7980-7ff1-42f0-addf-ba09789756a7" \
  -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"external_ref":"GANTNER-0A4F21","external_ref_label":"Door badge"}'

4. Send events. That is the whole integration for a reader.

curl -X POST "$BASE/clock/checkin" \
  -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"external_ref":"GANTNER-0A4F21","at":"2026-10-02T07:02:11Z"}'

That last call is safe to retry and safe to replay from an offline buffer. If the person is already clocked in, nothing is written. If they are coming back from lunch, the gap is recorded as a break automatically.

Authentication

Send the key as a bearer token on every request:

Authorization: Bearer ts_jHjHWS-_iFdL0LLBiUVFmUep4mT6IA58mS-KA9R_X_g

X-Api-Key: ts_… is accepted as well. Keys are 32 random bytes and always start with ts_.

The key is shown once, at creation. We store only a keyed hash of it, so there is no endpoint — and no support request — that can return it later. If you lose it, the administrator revokes it and issues another.

Treat it like a password: server-side only, never in a mobile app, a browser bundle, or a repository.

A key identifies a system, not a person. It belongs to exactly one workspace and can never see another. Anyone holding a write key can record time for anyone in that workspace, which is why keys are issued by administrators only and why every call is logged.

Scopes and location pinning

ScopeMay
readEvery GET
writeEvery GET, plus POST, PATCH and DELETE

A write attempted with a read key returns 403 read_only_key. Ask for the narrowest scope that does your job — a payroll export almost never needs to write.

A key can also be pinned to one location. A pinned key sees only that site's people and records, and may only write there. Anything outside it reads as 404 … not found rather than a permission error, because confirming that a record exists elsewhere would itself leak something.

Conventions

Errors

Every failure has the same shape:

{
  "error": "entry_overlap",
  "message": "This interval overlaps an entry this employee already has. Send `allow_overlap: true` if that is intended.",
  "docs": "https://timestamp.swissapp.io/docs/api#errors",
  "details": { "conflicts": [ … ] }
}

Match on error — it is a stable string. message is written for whoever reads your logs. details appears whenever there is something concrete to act on, such as the rows you collided with.

CodeHTTPMeans
missing_key401No Authorization header
invalid_key401Key not recognised
key_revoked401The workspace revoked this key
read_only_key403Write attempted with a read key
location_forbidden403A pinned key reaching outside its location
unknown_endpoint404No such path
method_not_allowed405Wrong verb; details.allowed lists the right ones
employee_not_found404No such person in this workspace, or not visible to a pinned key
entry_not_found, absence_not_found404Likewise
ambiguous_employee409Two people share that email — use employee_id or external_ref
employee_exists409Already an employee; details.employee_id tells you which
email_taken409That email belongs to an account outside this workspace
external_ref_taken409That reference already belongs to someone else
employee_archived409Archived people cannot clock in or receive hours
seat_limit_reached409The workspace has no free seats — an administrator must add them
already_checked_in, not_checked_in409The clock is not in the state your call assumed
entry_overlap, absence_overlap409Collides with an existing record; resend with allow_overlap: true if deliberate
location_required400 / 409Several locations are possible — send location_id
timestamp_in_future422at is ahead of now
timestamp_too_old422at is more than 7 days ago
timestamp_before_checkin422Check-out at or before the check-in
unknown_leave_type422Not a code from GET /leave-types
range_too_wide422Timesheet range longer than 400 days
invalid_request400Validation failed; message names the field
server_error500Ours, not yours. Safe to retry.

Naming a person

Anywhere a call concerns one person, identify them with one of:

FieldUse when
employee_idYou stored our UUID from GET /employees
external_refYou have your own identifier — a badge UID, a payroll number
emailThe person's workspace email address
{ "external_ref": "GANTNER-0A4F21" }
{ "email": "anna@example.ch" }
{ "employee_id": "69cf7980-7ff1-42f0-addf-ba09789756a7" }

External references are unique inside a workspace, and one person can hold several — a door badge and a payroll number, set by two different systems, without either overwriting the other. Register one with POST or PATCH /employees.

Ambiguity is never guessed. Two people sharing an email address give 409 ambiguous_employee rather than a coin flip over whose timesheet to write.


Clock

The live working day — one call per tap, no state on your side. These write the same records the customer's tablet writes, so somebody can tap your reader in the morning and the tablet after lunch and the day still adds up.

POST /clock/checkin write
{
  "external_ref": "GANTNER-0A4F21",
  "location_id": "…",              // only if the person works at several
  "at": "2026-10-02T07:02:11Z",  // optional, defaults to now
  "note": "north door"
}
// 201
{ "data": {
  "status": "checked_in",
  "idempotent": false,
  "entry_id": "7027c09f-…",
  "started_at": "2026-10-02T07:02:11Z",
  "break_inserted": null,
  "employee": { "id": "…", "full_name": "Anna Keller" },
  "location_id": "…",
  "today": { "work_minutes": 0, "break_minutes": 0, "entries": [ … ] }
}}

It is idempotent

If the person is already clocked in, nothing is written and the response comes back with idempotent: true and the entry that is already open. A reader that cannot tell whether its last request landed can simply send it again.

Breaks are inserted for you

If their most recent closed entry is work that ended earlier the same local day, the gap between then and now is recorded as a break before the new work entry opens. Two taps a day is all a person ever needs to do; lunch takes care of itself. break_inserted reports it when it happens.

at, and devices that go offline

A reader that loses the network should buffer taps and replay them later. Without at, every replayed event would land at replay time and a morning shift would look like it started after lunch. Bounds: it may not be in the future (more than a minute of clock skew is rejected, less is clamped to now), and not more than seven days old — past that it is a payroll correction, which belongs to an administrator rather than a device.

POST /clock/checkout write

Same body. Closes the open entry. Returns 409 not_checked_in when there is nothing open, and 422 timestamp_before_checkin when a replayed at precedes the entry it would close.

GET /clock/status read

With an employee reference in the query string: that person's state plus today's totals. With no arguments: everyone currently clocked in — which is what a dashboard, or a safety roll-call, actually wants.

GET /clock/status
GET /clock/status?external_ref=GANTNER-0A4F21

Time entries

Records rather than live events: yesterday's confirmed shifts, a migration off another system, a correction from whichever system owns the truth for one site.

GET /entries read

Query: an employee reference, from and to (local days), kind=work|break, location_id, limit, offset.

{ "data": [{
  "id": "342c90da-…",
  "employee_id": "…", "location_id": "…",
  "kind": "work",
  "start_at": "2026-09-29T07:00:00Z",
  "end_at": "2026-09-29T15:30:00Z",
  "minutes": 510, "open": false,
  "status": "closed", "source": "api",
  "note": "pushed by payroll sync",
  "created_at": "2026-10-02T09:08:33Z"
}], "pagination": { … } }
POST /entries write
{
  "external_ref": "GANTNER-0A4F21",
  "kind": "work",                      // work | break, default work
  "start_at": "2026-09-29T07:00:00Z",
  "end_at": "2026-09-29T15:30:00Z",    // required
  "note": "pushed by payroll sync",
  "allow_overlap": false
}

end_at is required here. To open a shift that is still running, use POST /clock/checkin, which handles the break logic and the concurrency properly.

Overlaps are refused. A retried import would otherwise store the same Tuesday twice and double somebody's week — so a collision returns 409 entry_overlap with the offending records in details.conflicts. Send allow_overlap: true when a split shift genuinely needs it. Intervals that merely touch (one ends 12:00, the next starts 12:00) are not an overlap.

PATCH /entries/{id} write
DELETE /entries/{id} write

PATCH accepts start_at, end_at, kind, note and allow_overlap. An entry cannot be re-opened by clearing end_at; delete it and check in instead.


Employees

GET /employees read

Query: limit, offset, include_archived=true.

{ "data": [{
  "id": "69cf7980-…",
  "full_name": "Anna Keller",
  "email": "anna@example.ch",
  "external_refs": [{ "external_ref": "GANTNER-0A4F21", "label": "Door badge" }],
  "contract_percentage": 80,
  "contract_minutes_per_week": 1968,
  "work_days_per_week": 5,
  "vacation_days_per_year": 25,
  "language": "de",
  "role": "standard",
  "enrolled": true,
  "archived": false,
  "location_ids": ["7112fa95-…"],
  "created_at": "2026-10-02T09:08:09Z"
}], "pagination": { … } }

Nothing PIN-related is ever returned. The tablet PIN is not readable by anyone, including us.

POST /employees write
{
  "full_name": "Anna Keller",            // required
  "email": "anna@example.ch",           // optional
  "external_ref": "GANTNER-0A4F21",
  "external_ref_label": "Door badge",
  "contract_percentage": 80,            // default 100
  "contract_minutes_per_week": 1968,    // default: derived from the percentage
  "work_days_per_week": 5,
  "vacation_days_per_year": 25,
  "language": "de",                      // de | en | fr | it
  "role": "standard",                   // standard | apprentice
  "location_ids": ["…"]               // optional in a single-site workspace
}

No email is sent. No invitation, no enrolment code. A nightly sync of forty people must not put forty "set your password" messages in forty inboxes. The administrator hands out enrolment codes from the Members screen when they are ready.

Seat limits apply. A full workspace returns 409 seat_limit_reached. An integration cannot quietly increase a customer's bill.

PATCH /employees/{id} write
{
  "archived": true,                     // archive or restore
  "external_ref": "PAYROLL-4471",       // adds a reference
  "remove_external_ref": "GANTNER-0A4F21",
  "location_ids": ["…"]               // replaces the assignment
}

References are added and removed individually, never replaced wholesale — so a badge reader registering a UID cannot drop the payroll number another system set.


Absences

An absence lands in the same inbox a request from the tablet lands in, marked as coming from the API.

GET /absences read

Query: an employee reference, status, from, to. Date filters select anything touching the window, so a holiday spanning a month boundary appears in both months.

POST /absences write
{
  "external_ref": "GANTNER-0A4F21",
  "leave_type": "sick",          // GET /leave-types for the codes
  "date_from": "2026-10-05",
  "date_to": "2026-10-06",       // defaults to date_from
  "half_day": "morning",         // morning | afternoon, single day only
  "start_time": "09:00",         // part-day illness, compensation
  "end_time": "13:00",
  "reason": "flu",
  "status": "pending",           // pending (default) | approved
  "allow_overlap": false
}

status defaults to pending deliberately: an approval has consequences for pay, and the quiet default should be the one that puts a human in front of it.

Sending status: "approved" also writes the schedule records that make the absence count, and tells you exactly which days were affected:

{ "data": {
  "id": "…", "status": "approved",
  "approval": {
    "override_dates": ["2026-11-02", "2026-11-03", "2026-11-04", "2026-11-05", "2026-11-06"],
    "skipped_dates": ["2026-11-07", "2026-11-08"]
  }
}}

Holiday is only spent on days the person was due to work — weekends, public holidays and their non-working weekdays are skipped and listed in skipped_dates, resolved exactly as the administrator's own screen resolves it. A request for "a fortnight off" correctly costs ten days, not fourteen.

PATCH /absences/{id} write
{ "status": "approved" | "rejected" | "cancelled", "decision_note": "…" }

Cancelling or rejecting an absence that was approved also removes the records it wrote. Re-sending a decision already in force is a no-op, not an error.


Timesheets

GET /timesheets?from=2026-09-01&to=2026-09-30 read

Hours computed by the same engine that produces the administrator's timesheet and the employee's monthly statement — so your payroll figure matches the screen your customer is looking at, rather than a sum of raw intervals that quietly disagrees with it.

With an employee reference: one object. Without: a page of employees. include_days=true adds the day-by-day breakdown. The range is capped at 400 days.

{ "data": {
  "employee": { "id": "…", "full_name": "Anna Keller" },
  "timezone": "Europe/Zurich",
  "scheduled_minutes": 788,
  "actual_minutes": 904,
  "variance_minutes": 116,
  "break_minutes": 0,
  "balance_minutes": 116,
  "vacation_days": 0, "sick_days": 0, "holiday_days": 1
}, "range": { "from": "2026-09-01", "to": "2026-09-30" } }

actual_minutes is not a sum of worked intervals. Like the on-screen timesheet, it credits a public holiday or an approved absence at the day's scheduled length. In the example above, 510 minutes were worked and 394 are a credited public holiday. If you want only the time somebody was physically clocked in, add up GET /entries instead.

balance_minutes is the running overtime balance as of to — the figure that actually gets settled.


Schedules and lookups

EndpointReturns
GET /locations The workspace's sites, with timezone and canton code
GET /leave-types The absence catalogue with each workspace's own policy — call this rather than hard-coding codes
GET /schedules?employee_id=…&from=&to= The working pattern in force and the day-level exceptions in the window

Limits, and what the customer sees

What this API will not do

Support

Questions about an integration, or something in this page that turned out to be wrong: info@bsc-swiss.ch. When reporting a problem, include the time of the call and the error code — the customer's call log will show the matching entry.

Timestamp is a product of Baier Sales & Consulting GmbH, Steinhausen ZG. Data is hosted in Switzerland.