API Conventions
Base URL and Versioning
All routes are served under the /api prefix with URI versioning. The default version is v1, so a typical endpoint looks like:
GET /api/v1/shifts
A few infrastructure and public routes are version-neutral and have no /v1 segment:
| Route | Purpose |
|---|---|
/api/health, /api/health/ready, /api/health/backup | Liveness, readiness and backup freshness |
/api/calendar/:token.ics | Employee calendar subscription feed |
/api/public-stats | Aggregate usage statistics for the marketing site |
Note that the BI feed is versioned: it lives at /api/v1/bi/..., not /api/bi/....
In non-production environments the OpenAPI (Swagger) UI is available at /api/docs.
Authentication
The web app authenticates with JWTs delivered as HTTP-only cookies:
POST /api/v1/auth/loginissues a short-lived access token (15 minutes) and a refresh token (30 days).POST /api/v1/auth/refreshrotates the refresh token. The token can be read from the cookie or sent in the body.- Every route requires a valid access token unless it is explicitly marked public. Machine-to-machine integrations (BI feed, public stats) use their own static bearer tokens instead — see the relevant pages.
Refresh-token rotation grace window
Refresh tokens rotate on every use, and replaying an already-rotated token is normally treated as token theft (all of the user's sessions are revoked). Because an installed PWA and a browser tab share one cookie jar, two contexts can legitimately refresh at nearly the same moment. To avoid logging those users out:
- A token that was revoked by rotation and is replayed within
REFRESH_ROTATION_GRACE_SECONDS(default60) is treated as a benign race: a fresh session is issued and nothing is revoked. - Replays outside the window, or replays of tokens revoked for other reasons (logout, password change, role change), still revoke every session.
- Setting
REFRESH_ROTATION_GRACE_SECONDS=0restores strict rotation without a code change.
On startup the web client only logs the user out on a definitive 401; network errors and 5xx responses are retried instead.
Rate Limiting
A global limit of 60 requests per 60 seconds applies to every route unless a route overrides it. The limit is tracked per caller by EmployeeThrottlerGuard:
- Authenticated requests are counted per employee (or per user, for logins without an employee record). Many devices behind one venue's shared internet connection therefore no longer share a single bucket.
- Unauthenticated requests are counted per real client IP. The API runs behind a reverse proxy with Fastify
trustProxyenabled, so the IP comes fromX-Forwarded-For.
Stricter per-route limits:
| Routes | Limit |
|---|---|
Auth (login, refresh, forgot-password, reset-password, accept-invite, change-password) | 5 / minute |
Kiosk (/kiosk/*) | 10 / minute |
| Public stats | 10 / minute |
| Calendar ICS feed | 60 / minute |
Booking webhooks are exempt from throttling. A throttled request returns 429 Too Many Requests.
Clock Events and Offline Sync
Devices generate a UUID eventId for every clock event and may submit them later in a batch. POST /api/v1/clock-events/batch:
- Returns a bare array of per-event results (not an object wrapper). Match results to your events by
eventId, since the server may regroup them. - Deduplicates on
eventId. A replayed event returns aduplicateresult instead of creating a second record. - Processes each event in its own savepoint, so one rejected event does not roll back the others.
- Enforces the same authorization as the single-event endpoint: employees may only submit their own events; supervisors and above may submit on behalf of others in scope.
Clients should retry network errors, 429 and 5xx with backoff, and treat 4xx responses and per-event errors as final. The web app shows those final failures to the employee.
Conventions Worth Knowing
- Timestamps are ISO 8601 with a timezone. Calendar-day logic (payroll days, BI buckets) uses
Europe/Helsinki. - List limits are validated server-side (
@Maxon the query DTO, typically 100–500 depending on the resource). A limit above the maximum is rejected with400, not silently clamped. - CSV exports use the Finnish convention:
;as the delimiter,,as the decimal separator and a UTF-8 BOM. The Netvisor workday CSV and the employee import template are exceptions. - Shift cancellation (
POST /api/v1/shifts/:id/cancel) requires acategory. Onlyemployee_travelled_or_arrivedqualifies for TES cancellation compensation. - Employee numbers are assigned by the server as sequential integers. Any client-supplied value is ignored.