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 building | You 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.
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
| Scope | May |
|---|---|
read | Every GET |
write | Every 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
- Timestamps are ISO-8601 instants, always returned in UTC:
2026-10-02T07:00:00Z. - Dates are
YYYY-MM-DDand mean a local calendar day in the location's own timezone — which matters in Switzerland twice a year. - Lists return
{ "data": [...], "pagination": { "limit", "offset", "total" } }.limitdefaults to 100, maximum 500. - Single objects return
{ "data": { … } }. - A
POSTthat creates returns 201; aDELETEreturns 204 with no body. - The version lives in the path.
/v1is the only version; when a/v2ever exists,/v1keeps working.
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.
| Code | HTTP | Means |
|---|---|---|
missing_key | 401 | No Authorization header |
invalid_key | 401 | Key not recognised |
key_revoked | 401 | The workspace revoked this key |
read_only_key | 403 | Write attempted with a read key |
location_forbidden | 403 | A pinned key reaching outside its location |
unknown_endpoint | 404 | No such path |
method_not_allowed | 405 | Wrong verb; details.allowed lists the right ones |
employee_not_found | 404 | No such person in this workspace, or not visible to a pinned key |
entry_not_found, absence_not_found | 404 | Likewise |
ambiguous_employee | 409 | Two people share that email — use employee_id or external_ref |
employee_exists | 409 | Already an employee; details.employee_id tells you which |
email_taken | 409 | That email belongs to an account outside this workspace |
external_ref_taken | 409 | That reference already belongs to someone else |
employee_archived | 409 | Archived people cannot clock in or receive hours |
seat_limit_reached | 409 | The workspace has no free seats — an administrator must add them |
already_checked_in, not_checked_in | 409 | The clock is not in the state your call assumed |
entry_overlap, absence_overlap | 409 | Collides with an existing record; resend with allow_overlap: true if deliberate |
location_required | 400 / 409 | Several locations are possible — send location_id |
timestamp_in_future | 422 | at is ahead of now |
timestamp_too_old | 422 | at is more than 7 days ago |
timestamp_before_checkin | 422 | Check-out at or before the check-in |
unknown_leave_type | 422 | Not a code from GET /leave-types |
range_too_wide | 422 | Timesheet range longer than 400 days |
invalid_request | 400 | Validation failed; message names the field |
server_error | 500 | Ours, not yours. Safe to retry. |
Naming a person
Anywhere a call concerns one person, identify them with one of:
| Field | Use when |
|---|---|
employee_id | You stored our UUID from GET /employees |
external_ref | You have your own identifier — a badge UID, a payroll number |
email | The 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.
{
"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.
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.
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.
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": { … } }
{
"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 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
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.
{
"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.
{
"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.
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.
{
"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.
{ "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
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
| Endpoint | Returns |
|---|---|
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
- Every call is logged — method, path, result, which key, which person it concerned, and the time. The workspace administrator reads this in Settings, which means they can see your integration working without asking you, and you can debug without asking them.
- Revocation is immediate, from the next request onwards.
- Twenty live keys per workspace.
- 500 records per page, 100 by default.
- No rate limit today. Please behave as though there were one: back off on
5xx, do not poll/clock/statusin a tight loop, and prefer webhook-free batching where you can.
What this API will not do
- Write schedules. Building a working-time plan involves choices an HTTP body cannot express honestly. Push day-level absences instead, or let the customer use the Schedules screen.
- Touch PINs. Nothing exposes or sets a tablet PIN. Enrolment stays with the administrator.
- Send email. Nothing here triggers an invitation, an enrolment code or a report.
- Reach another workspace. A key is bound to exactly one, with no exceptions and no cross-tenant endpoint.
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.