Files
accounted/skills/accounted-api/references/employees.md
T
3447da027a feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* 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>
2026-08-27 17:35:47 +02:00

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_active if 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 review or 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"
  }
}