* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
34 KiB
Employees endpoints
The employee register plus absence (frånvaro), vacation balances and year close, and payroll cutover opening balances. Running payroll itself: salary-runs.md.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.
GET /api/v1/companies/{companyId}/employees
List employees for a company.
scope:payroll:read · risk:low · idempotent
Returns active employees in created-first order. Pass ?include_inactive=true to include soft-deleted (is_active=false) rows. Use ?search to match against first or last name. Personnummer is masked (birthdate visible, last-4 hidden); use GET /employees/{id} for the full value.
Use when: You need a roster: for building a UI picker, resolving employee_id before adding to a salary run, or syncing an external HR system. Do not use for: Fetching a single employee you already know the id of: use GET /api/v1/companies/{companyId}/employees/{id}. Salary calculations live on /salary-runs/{id}.
Pitfalls:
- Inactive employees are hidden by default; soft-delete via DELETE sets is_active=false (BFL 7 kap retention).
- personnummer is masked in the list response (GDPR Art.5(1)(c) data minimisation). The detail endpoint returns the full value.
- salary_type drives which field is meaningful: monthly_salary for monthly, hourly_rate for hourly. The other is null.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Response 200:
{
data: { id: string, first_name: string, last_name: string, personnummer_masked: string, employment_type: "employee" | "company_owner" | "board_member", employment_start: string, employment_end: string, salary_type: "monthly" | "hourly", monthly_salary: number, hourly_rate: number, f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", is_active: boolean, created_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": [
{
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"salary_type": "monthly",
"monthly_salary": 35000,
"hourly_rate": null,
"f_skatt_status": "a_skatt",
"is_active": true,
"created_at": "2024-01-15T08:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
POST /api/v1/companies/{companyId}/employees
Create an employee.
scope:payroll:write · risk:low · idempotent · dry-run · reversible
Creates a new employee for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing. The personnummer in the request body must be 12 digits (ÅÅÅÅMMDDNNNN); the response echoes a masked form (birthdate + XXXX): GDPR Art.5(1)(c).
Use when: You need to register a new employee before adding them to a salary run. Use dry-run first to catch validation errors (missing tax table, salary amount, F-skatt mismatch) before committing. Do not use for: Updating an existing employee (PATCH instead). Soft-deactivating (DELETE: sets is_active=false). Hard-deleting (the API does not expose hard delete; BFL 7 kap retention).
Pitfalls:
- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.
- personnummer must be exactly 12 digits with the YYYYMMDD prefix (not the short 10-digit form).
- Duplicate personnummer within a company returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER. Personnummer is unique per (company_id, personnummer).
- For A-skatt employees who are not sidoinkomst, tax_table_number is required (29-42).
- salary_type drives which salary field is required: monthly_salary for monthly, hourly_rate for hourly.
- The response masks personnummer; never echo back the supplied value. Detail endpoint (deliberate drill-in) returns the full value.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
first_name: string,
last_name: string,
personnummer: string,
employment_type?: "employee" | "company_owner" | "board_member",
employment_start: string,
employment_end?: string,
employment_degree?: number,
hours_per_week?: number,
workdays_per_week?: number,
salary_type?: "monthly" | "hourly",
monthly_salary?: number,
hourly_rate?: number,
tax_table_number?: number,
tax_column?: number,
tax_municipality?: string,
is_sidoinkomst?: boolean,
f_skatt_status?: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified",
clearing_number?: string,
bank_account_number?: string,
vacation_rule?: "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning",
vacation_days_per_year?: number,
semestertillagg_rate?: number,
email?: string,
phone?: string,
address_line1?: string,
postal_code?: string,
city?: string,
vaxa_stod_eligible?: boolean,
vaxa_stod_start?: string,
vaxa_stod_end?: string,
jamkning_percentage?: number,
jamkning_valid_from?: string,
jamkning_valid_to?: string,
default_dimensions?: Record<string, string>
}
Example request:
{
"first_name": "Anna",
"last_name": "Andersson",
"personnummer": "YYYYMMDDNNNN",
"employment_type": "employee",
"employment_start": "2024-01-15",
"salary_type": "monthly",
"monthly_salary": 35000,
"tax_table_number": 33,
"tax_column": 1,
"tax_municipality": "Stockholm"
}
Response 200:
{
data: {
id: string,
first_name: string,
last_name: string,
personnummer_masked: string,
employment_type: "employee" | "company_owner" | "board_member",
employment_start: string,
employment_end: string,
employment_degree: number,
salary_type: "monthly" | "hourly",
monthly_salary: number,
hourly_rate: number,
tax_table_number: number,
tax_column: number,
tax_municipality: string,
is_sidoinkomst: boolean,
f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified",
vacation_rule: string,
vacation_days_per_year: number,
is_active: boolean,
created_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"employment_degree": 100,
"salary_type": "monthly",
"monthly_salary": 35000,
"hourly_rate": null,
"tax_table_number": 33,
"tax_column": 1,
"tax_municipality": "Stockholm",
"is_sidoinkomst": false,
"f_skatt_status": "a_skatt",
"vacation_rule": "procentregeln",
"vacation_days_per_year": 25,
"is_active": true,
"created_at": "2024-01-15T08:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/{companyId}/employees/{id}
Get a single employee.
scope:payroll:read · risk:low · idempotent
Returns the full employee record including the 12-digit personnummer, bank details, tax configuration, and contact info. This is the deliberate drill-in for an id you already know: list calls mask personnummer.
Use when: You have an employee id and need every field (tax table, bank account, vacation rule): typically to render an edit form or to construct a payroll calculation input. Do not use for: Rosters or pickers (use the list endpoint: personnummer is masked there).
Pitfalls:
- The response includes the full personnummer. Treat it as a national identifier (GDPR Art.5(1)(c)): do not propagate it to logs or external systems beyond what your integration strictly requires.
- Inactive (soft-deleted) employees are returned by the detail endpoint; check
is_activeif your flow should skip them.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: {
id: string,
first_name: string,
last_name: string,
personnummer: string,
employment_type: "employee" | "company_owner" | "board_member",
employment_start: string,
employment_end: string,
employment_degree: number,
hours_per_week: number,
workdays_per_week: number,
salary_type: "monthly" | "hourly",
monthly_salary: number,
hourly_rate: number,
tax_table_number: number,
tax_column: number,
tax_municipality: string,
is_sidoinkomst: boolean,
f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified",
clearing_number: string,
bank_account_number: string,
vacation_rule: string,
vacation_days_per_year: number,
semestertillagg_rate: number,
email: string,
phone: string,
address_line1: string,
postal_code: string,
city: string,
vaxa_stod_eligible: boolean,
vaxa_stod_start: string,
vaxa_stod_end: string,
jamkning_percentage: number,
jamkning_valid_from: string,
jamkning_valid_to: string,
default_dimensions: Record<string, string>,
is_active: boolean,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer": "YYYYMMDDNNNN",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"salary_type": "monthly",
"monthly_salary": 35000,
"f_skatt_status": "a_skatt",
"is_active": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/{companyId}/employees/{id}
Update an employee.
scope:payroll:write · risk:low · idempotent · dry-run
Partial update of an employee. Only the fields supplied in the body are changed. Supports ?dry_run=true to validate the merged record without committing. Personnummer changes are NOT permitted via this endpoint: the natural-person identity is immutable post-creation.
Use when: You need to change tax configuration, bank details, salary amount, or contact info on an existing employee. Do not use for: Changing personnummer (not supported: create a new employee if the natural-person identity changes, which is a rare edge case). Soft-deleting (use DELETE).
Pitfalls:
- personnummer in the body is ignored by this endpoint. To change it you must DELETE and recreate.
- salary_type changes require the matching salary field in the same request: switching to monthly without monthly_salary returns 400.
- tax_table_number changes only take effect on future salary runs; runs already in
reviewor beyond use a frozen snapshot.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Request body:
{
first_name?: string,
last_name?: string,
personnummer?: string,
employment_type?: "employee" | "company_owner" | "board_member",
employment_start?: string,
employment_end?: string,
employment_degree?: number,
hours_per_week?: number,
workdays_per_week?: number,
salary_type?: "monthly" | "hourly",
monthly_salary?: number,
hourly_rate?: number,
tax_table_number?: number,
tax_column?: number,
tax_municipality?: string,
is_sidoinkomst?: boolean,
f_skatt_status?: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified",
clearing_number?: string,
bank_account_number?: string,
vacation_rule?: "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning",
vacation_days_per_year?: number,
semestertillagg_rate?: number,
email?: string,
phone?: string,
address_line1?: string,
postal_code?: string,
city?: string,
vaxa_stod_eligible?: boolean,
vaxa_stod_start?: string,
vaxa_stod_end?: string,
jamkning_percentage?: number,
jamkning_valid_from?: string,
jamkning_valid_to?: string,
default_dimensions?: Record<string, string>
}
Example request:
{
"monthly_salary": 38000,
"tax_municipality": "Göteborg"
}
Response 200:
{
data: {
id: string,
first_name: string,
last_name: string,
employment_type: "employee" | "company_owner" | "board_member",
employment_start: string,
employment_end: string,
employment_degree: number,
hours_per_week: number,
workdays_per_week: number,
salary_type: "monthly" | "hourly",
monthly_salary: number,
hourly_rate: number,
tax_table_number: number,
tax_column: number,
tax_municipality: string,
is_sidoinkomst: boolean,
f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified",
clearing_number: string,
bank_account_number: string,
vacation_rule: string,
vacation_days_per_year: number,
semestertillagg_rate: number,
email: string,
phone: string,
address_line1: string,
postal_code: string,
city: string,
vaxa_stod_eligible: boolean,
vaxa_stod_start: string,
vaxa_stod_end: string,
jamkning_percentage: number,
jamkning_valid_from: string,
jamkning_valid_to: string,
default_dimensions: Record<string, string>,
is_active: boolean,
created_at: string,
updated_at: string,
personnummer_masked: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"id": "a8f1…",
"monthly_salary": 38000
}
}
DELETE /api/v1/companies/{companyId}/employees/{id}
Soft-delete an employee.
scope:payroll:write · risk:low · idempotent · dry-run · reversible
Sets is_active=false. The row is preserved because past salary runs reference it via salary_run_employees and those verifikationer are räkenskapsinformation under BFL 7 kap (BFL retention attaches to the verifikationer themselves, not strictly to the personnummer attribute on the master row). Hard delete is never exposed.
Use when: An employee has left the company and should no longer appear in active rosters or default to new salary runs.
Do not use for: Reactivating later (PATCH is_active=true instead). Hard-deleting (not supported: retention).
Pitfalls:
- Idempotent: deleting an already-inactive employee returns 204 No Content (the same as the first call).
- The row is NOT removed from the database: re-creating with the same personnummer returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER even after soft-delete.
- Past salary runs still reference this employee; their data continues to surface in GET /salary-runs/{id} and SIE exports.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 204.
GET /api/v1/companies/{companyId}/employees/{id}/absence
List absence days for an employee in a date range.
scope:payroll:read · risk:low · idempotent
Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.
Use when: You need an employee's registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar. Do not use for: The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.
Pitfalls:
- Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.
- A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).
- Rows may reference the salary run that consumed them via salary_run_employee_id.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: { salary_absence_day_id: string, absence_date: string, absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave", hours: number, notes: string, salary_run_employee_id: string, created_at: string, updated_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": [
{
"salary_absence_day_id": "abs_91d2…",
"absence_date": "2026-03-03",
"absence_type": "sick",
"hours": 8,
"notes": null
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/{companyId}/employees/{id}/absence
Register absence for an employee over a date range.
scope:payroll:write · risk:low · idempotent · dry-run · reversible
Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.
Use when: "Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback). Do not use for: Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.
Pitfalls:
- Weekends are skipped by default: pass include_weekends=true for schedules that span them.
- Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.
- A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).
- Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Request body:
{
from: string,
to: string,
absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave",
hours_per_day?: number,
notes?: string,
include_weekends?: boolean
}
Example request:
{
"from": "2026-03-03",
"to": "2026-03-07",
"absence_type": "sick"
}
Response 200:
{
data: {
count: number,
days: { salary_absence_day_id?: string, absence_date: string, absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave", hours: number, notes?: string, salary_run_employee_id?: string, created_at?: string, updated_at?: string }[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"count": 5,
"days": [
{
"absence_date": "2026-03-03",
"absence_type": "sick",
"hours": 8
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/{companyId}/employees/{id}/absence
Delete absence days for an employee in a date range.
scope:payroll:write · risk:low · idempotent · dry-run
Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.
Use when: An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days". Do not use for: Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.
Pitfalls:
- Without ?type, ALL absence types in the range are deleted.
- deleted_count: 0 with a 200 means nothing matched: not an error.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: { deleted_count: number },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"deleted_count": 2
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/{companyId}/employees/{id}/opening-balances
Get an employee's payroll cutover opening balances.
scope:payroll:read · risk:low · idempotent
Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.
Use when: Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false). Do not use for: The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.
Pitfalls:
- 404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.
- locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: {
employee_opening_balances_id: string,
employee_id: string,
cutover_date: string,
ytd_gross: number,
ytd_tax: number,
ytd_net: number,
vacation_paid_days_remaining: number,
vacation_days_taken_this_year: number,
vacation_saved_days_by_year: Record<string, number>,
opening_semester_liability: number,
opening_semester_liability_avgifter: number,
karens_periods_adjustment: number,
locked: boolean,
locked_by_run_id: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"vacation_paid_days_remaining": 12.5,
"locked": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/{companyId}/employees/{id}/opening-balances
Set an employee's payroll cutover opening balances.
scope:payroll:write · risk:medium · idempotent · dry-run · reversible
Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, paid days already taken this vacation year, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.
Use when: Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances. Do not use for: SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.
Pitfalls:
- Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.
- 409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.
- The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.
- YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Request body:
{
cutover_date: string,
ytd_gross?: number,
ytd_tax?: number,
ytd_net?: number,
vacation_paid_days_remaining?: number,
vacation_days_taken_this_year?: number,
vacation_saved_days_by_year?: Record<string, number>,
opening_semester_liability?: number,
opening_semester_liability_avgifter?: number,
karens_periods_adjustment?: number
}
Example request:
{
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"ytd_tax": 48000,
"ytd_net": 162000,
"vacation_paid_days_remaining": 12.5,
"vacation_saved_days_by_year": {
"2025": 5
},
"opening_semester_liability": 42000,
"opening_semester_liability_avgifter": 13196.4,
"karens_periods_adjustment": 1
}
Response 200:
{
data: {
employee_opening_balances_id: string,
employee_id: string,
cutover_date: string,
ytd_gross: number,
ytd_tax: number,
ytd_net: number,
vacation_paid_days_remaining: number,
vacation_days_taken_this_year: number,
vacation_saved_days_by_year: Record<string, number>,
opening_semester_liability: number,
opening_semester_liability_avgifter: number,
karens_periods_adjustment: number,
locked: boolean,
locked_by_run_id: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
Get an employee's current vacation balance.
scope:payroll:read · risk:low · idempotent
Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.
Use when: Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close. Do not use for: The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.
Pitfalls:
- 404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.
- remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.
- The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: {
employee_vacation_balance_id: string,
employee_id: string,
vacation_year_start: string,
entitled_days: number,
accrued_days: number,
taken_days: number,
remaining_days: number,
saved_days: Record<string, number>,
saved_days_total: number,
forced_payout_days: number,
estimated_liability_sek: number
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"employee_id": "emp_77b2…",
"vacation_year_start": "2026-01-01",
"entitled_days": 25,
"taken_days": 10,
"remaining_days": 15,
"saved_days": {
"2025": 5
},
"saved_days_total": 5,
"estimated_liability_sek": 31151.4
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/{companyId}/employees/opening-balances
Bulk-set payroll cutover opening balances (atomic).
scope:payroll:write · risk:medium · idempotent · dry-run · reversible
Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.
Use when: Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs. Do not use for: Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).
Pitfalls:
- Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.
- Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.
- Duplicate employee_id within items is rejected outright.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
items: { employee_id: string, cutover_date: string, ytd_gross?: number, ytd_tax?: number, ytd_net?: number, vacation_paid_days_remaining?: number, vacation_days_taken_this_year?: number, vacation_saved_days_by_year?: Record<string, number>, opening_semester_liability?: number, opening_semester_liability_avgifter?: number, karens_periods_adjustment?: number }[]
}
Example request:
{
"items": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"ytd_tax": 48000,
"ytd_net": 162000
}
]
}
Response 200:
{
data: {
count: number,
rows: { employee_opening_balances_id: string, employee_id: string, cutover_date: string, locked: boolean }[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"count": 1,
"rows": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/{companyId}/salary/vacation-year-close
Close a vacation year (semesterberedning + arsavslut).
scope:payroll:write · risk:high · idempotent · dry-run
Rolls every active employee's vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap).
Use when: Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API. Do not use for: Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them).
Pitfalls:
- dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call.
- 409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor.
- 423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually.
- Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §).
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{ vacation_year_start?: string, book_adjustment?: boolean }
Example request:
{
"book_adjustment": true
}
Response 200:
{
data: { vacation_year_closure_id: string, adjustment_entry_id: string, report?: unknown },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"vacation_year_closure_id": "vyc_a1b2…",
"adjustment_entry_id": "je_c3d4…",
"report": {
"vacation_year_start": "2025-01-01",
"rows": [],
"sek": {
"drift_2920": 8690.84
}
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}