Menu
Cookbooks
API reference
- Overview
- Companies
- Customers
- Invoices
- Articles
- Suppliers
- Supplier invoices
- Supplier payment files
- Expense claims
- Transactions
- Reconciliation
- Bank accounts
- Journal entries
- Voucher gap explanations
- Fiscal periods
- Accounts
- Fixed assets
- Documents
- Inbox items
- Dimensions
- Employees
- Salary runs
- Reports
- Imports
- Compliance check
- Skatteverket
- Peppol
- Webhooks
- Operations
- Health
Employees
Payroll roster: CRUD with personnummer masking on list endpoints.
Endpoints
GET/api/v1/companies/:companyId/employees: List employees for a company.GET/api/v1/companies/:companyId/employees/:id: Get a single employee.GET/api/v1/companies/:companyId/employees/:id/absence: List absence days for an employee in a date range.GET/api/v1/companies/:companyId/employees/:id/benefits: List the benefits (förmåner) registered on an employee.GET/api/v1/companies/:companyId/employees/:id/opening-balances: Get an employee's payroll cutover opening balances.GET/api/v1/companies/:companyId/employees/:id/recurring-lines: List recurring payslip lines for an employee.GET/api/v1/companies/:companyId/employees/:id/vacation-balance: Get an employee's current vacation balance.GET/api/v1/companies/:companyId/employees/:id/worked-days: List worked days (hours per date) for an employee in a date range.GET/api/v1/companies/:companyId/salary-runs/:id/employees: List per-employee results of a salary run.GET/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId: Get one employee's payslip in a salary run.POST/api/v1/companies/:companyId/employees: Create an employee.POST/api/v1/companies/:companyId/employees/:id/benefits: Register a benefit (förmån) on an employee.POST/api/v1/companies/:companyId/employees/:id/recurring-lines: Create a recurring payslip line for an employee.POST/api/v1/companies/:companyId/salary-runs/:id/employees: Add an employee to a draft salary run.POST/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines: Add a payslip line to an employee in a draft salary run.PATCH/api/v1/companies/:companyId/employees/:id: Update an employee.PATCH/api/v1/companies/:companyId/employees/:id/benefits/:benefitId: Partially update a benefit (förmån) on an employee.PATCH/api/v1/companies/:companyId/employees/:id/recurring-lines/:lineId: Update a recurring payslip line.PATCH/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId: Set this run's base salary for one employee.PUT/api/v1/companies/:companyId/employees/:id/absence: Register absence for an employee over a date range.PUT/api/v1/companies/:companyId/employees/:id/opening-balances: Set an employee's payroll cutover opening balances.PUT/api/v1/companies/:companyId/employees/:id/worked-days: Register worked hours per day for an employee (bulk upsert).PUT/api/v1/companies/:companyId/employees/opening-balances: Bulk-set payroll cutover opening balances (atomic).DELETE/api/v1/companies/:companyId/employees/:id: Soft-delete an employee.DELETE/api/v1/companies/:companyId/employees/:id/absence: Delete absence days for an employee in a date range.DELETE/api/v1/companies/:companyId/employees/:id/benefits/:benefitId: Remove a benefit (förmån) from an employee.DELETE/api/v1/companies/:companyId/employees/:id/recurring-lines/:lineId: Delete a recurring payslip line, or deactivate it if a run already used it.DELETE/api/v1/companies/:companyId/employees/:id/worked-days: Delete worked days for an employee in a date range.DELETE/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId: Remove an employee from a draft salary run.
GET /api/v1/companies/:companyId/employees
employees.list · scope payroll:read
List employees for a company.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
employment_type | "employee" | "company_owner" | "board_member" | no | Only employees with this employment type. |
search | string | no | Case-insensitive match anywhere in the first or last name, 1-200 characters. |
include_inactive | "true" | "false" | no | true also returns inactive employees. Default: active only. |
cursor | string | no | Opaque cursor from the previous page's meta.next_cursor. Omit for the first page. |
limit | number | no | Page size, 1-100 (default 50). Larger values are clamped to 100. |
Response fields
| Name | Type |
|---|---|
[].id | string |
[].first_name | string |
[].last_name | string |
[].personnummer_masked | string |
[].employment_type | "employee" | "company_owner" | "board_member" |
[].employment_start | string |
[].employment_end | string | null |
[].salary_type | "monthly" | "hourly" |
[].monthly_salary | number | null |
[].hourly_rate | number | null |
[].f_skatt_status | "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified" |
[].is_active | boolean |
[].created_at | string |
Example response
{
"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
}
}
GET /api/v1/companies/:companyId/employees/:id
employees.get · scope payroll:read
Get a single employee.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Response fields
| Name | Type |
|---|---|
id | string |
first_name | string |
last_name | string |
personnummer | string |
employment_type | "employee" | "company_owner" | "board_member" |
employment_start | string |
employment_end | string | null |
employment_degree | number |
hours_per_week | number |
workdays_per_week | number |
salary_type | "monthly" | "hourly" |
monthly_salary | number | null |
hourly_rate | number | null |
tax_table_number | number | null |
tax_column | number | null |
tax_municipality | string | null |
is_sidoinkomst | boolean |
f_skatt_status | "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified" |
clearing_number | string | null |
bank_account_number | string | null |
vacation_rule | string |
vacation_days_per_year | number |
semestertillagg_rate | number |
vacation_pay_rate | number | null |
email | string | null |
phone | string | null |
address_line1 | string | null |
postal_code | string | null |
city | string | null |
vaxa_stod_eligible | boolean |
vaxa_stod_start | string | null |
vaxa_stod_end | string | null |
jamkning_percentage | number | null |
jamkning_valid_from | string | null |
jamkning_valid_to | string | null |
default_dimensions | object |
is_active | boolean |
created_at | string |
updated_at | string |
Example response
{
"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"
}
}
GET /api/v1/companies/:companyId/employees/:id/absence
employees.absence.list · scope payroll:read
List absence days for an employee in a date range.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
from | string | yes | YYYY-MM-DD. First day of the range (inclusive). Required. |
to | string | yes | YYYY-MM-DD. Last day of the range (inclusive), not before from. Required. |
type | "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave" | no | Only days of this absence type. Default: every type. |
Response fields
| Name | Type |
|---|---|
[].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 | null |
[].salary_run_employee_id | string | null |
[].created_at | string |
[].updated_at | string |
Example response
{
"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"
}
}
GET /api/v1/companies/:companyId/employees/:id/benefits
employees.benefits.list · scope payroll:read
List the benefits (förmåner) registered on an employee.
Returns every benefit row on the employee, active and inactive, newest validity window first (valid_from descending, then created_at). Optional ?active=true|false filter. No cursor pagination: an employee carries a handful of rows.
Use when: You need to see which förmåner the salary engine will derive for an employee (bilförmån, kostförmån, cykelförmån, bostad, friskvård, annat), reconcile against an HR system, or find the employee_benefit_id to update or remove.
Don't use for: The derived payslip line and its tax effect: that lives on the salary run after :calculate. Standing deductions (bruttolöneavdrag, fackavgift): the recurring-lines register.
Pitfalls
- The monthly förmånsvärde is added to the tax and arbetsgivaravgift basis when a run is calculated (POST /salary-runs/{id}/calculate); it is never paid out.
- A run picks a row up when is_active is true and valid_from <= payment_date <= valid_to (valid_to null = open-ended). Rows outside that window are listed here but derive nothing.
- annual_market_value is populated for bike benefits only (read from the stored calculation inputs); other types carry null.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
active | "true" | "false" | no | true returns only rows with is_active=true, false only inactive rows. Default: both. |
Response fields
| Name | Type |
|---|---|
[].employee_benefit_id | string |
[].benefit_type | "bike" | "car" | "meals" | "housing" | "wellness" | "other" |
[].description | string |
[].monthly_value | number |
[].annual_market_value | number | null |
[].valid_from | string |
[].valid_to | string | null |
[].is_active | boolean |
[].metadata | object |
[].created_at | string |
[].updated_at | string |
Example response
{
"data": [
{
"employee_benefit_id": "ben_4f2a…",
"benefit_type": "car",
"description": "Bilförmån Volvo XC40",
"monthly_value": 4275,
"annual_market_value": null,
"valid_from": "2026-01-01",
"valid_to": null,
"is_active": true,
"metadata": {},
"created_at": "2026-01-05T09:12:00Z",
"updated_at": "2026-01-05T09:12:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/:companyId/employees/:id/opening-balances
employees.opening-balances.get · scope payroll:read
Get an employee's payroll cutover opening balances.
Returns the opening balances set for a mid-year migration (YTD gross/tax/net, the five vacation pools Betalda/Sparade per år/Obetalda/Förskott/Extra betalda with their as-of date, opening semesterlöneskuld and förskottsskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run. ytd_net is null when the previous system could not export historical net pay.
Use when: Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).
Don't 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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Response fields
| Name | Type |
|---|---|
employee_opening_balances_id | string | null |
employee_id | string |
cutover_date | string |
ytd_gross | number |
ytd_tax | number |
ytd_net | number | null |
vacation_paid_days_remaining | number |
vacation_days_taken_this_year | number |
vacation_saved_days_by_year | object |
opening_semester_liability | number |
opening_semester_liability_avgifter | number |
karens_periods_adjustment | number |
vacation_as_of_date | string | null |
vacation_unpaid_days_remaining | number |
vacation_advance_days_remaining | number |
vacation_extra_paid_days_remaining | number |
opening_advance_vacation_debt | number |
locked | boolean |
locked_by_run_id | string | null |
Example response
{
"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"
}
}
GET /api/v1/companies/:companyId/employees/:id/recurring-lines
employees.recurring-lines.list · scope payroll:read
List recurring payslip lines for an employee.
Returns the employee's standing monthly payslip rows (gross and net deductions such as a benefit bike bruttolöneavdrag, a union fee, or a net deduction for a benefit the employee pays for), newest valid_from first. Both active and deactivated lines are returned unless ?active filters them.
Use when: You need to see what the salary engine will derive for an employee every month, to reconcile with an HR system, or to find the employee_recurring_line_id to update or delete.
Don't use for: The derived payslip rows of one run: those are on the salary run detail after :calculate. Taxable benefits in kind (bilförmån, kostförmån): use the employee benefits endpoints.
Pitfalls
- Rows are re-derived on every :calculate for runs whose payment_date falls inside valid_from..valid_to (valid_to null = open-ended). Hand edits to a derived payslip row are overwritten by the next :calculate.
- The amount sign follows the item type: every supported type is a deduction and must be negative (e.g. -670.17 for a benefit bike bruttolöneavdrag). The API rejects the wrong sign with 400 VALIDATION_ERROR on field amount.
- account_number overrides the default BAS account for the derived payslip row; null lets the engine use its item-type mapping.
- Draft-only per-run edits (a one-off change on one payslip) still go through the salary-runs lines endpoints, not through recurring lines.
- Deactivated lines (is_active=false) are listed too: a line that a booked run derived from cannot be deleted, only deactivated, so history keeps it.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
active | "true" | "false" | no | true returns active lines only, false deactivated lines only. Default: every line. |
Response fields
| Name | Type |
|---|---|
[].employee_recurring_line_id | string |
[].item_type | "gross_deduction_pension" | "gross_deduction_other" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" |
[].description | string |
[].amount | number |
[].account_number | string | null |
[].valid_from | string |
[].valid_to | string | null |
[].is_active | boolean |
[].metadata | object |
[].created_at | string |
[].updated_at | string |
Example response
{
"data": [
{
"employee_recurring_line_id": "erl_5b1c…",
"item_type": "gross_deduction_other",
"description": "Förmånscykel bruttolöneavdrag",
"amount": -670.17,
"account_number": null,
"valid_from": "2026-01-01",
"valid_to": null,
"is_active": true,
"metadata": {}
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/:companyId/employees/:id/vacation-balance
employees.vacation-balance.get · scope payroll:read
Get an employee's current vacation balance.
Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining paid days, sparade dagar still held per origin year (Semesterlagen 5-year rule; saved_days_taken shows what saved vacation lines consumed this year), the unpaid (Obetalda) and advance (Förskott) pools from the cutover import, 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.
Don't 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.
- unpaid_days and advance_days are the cutover pools minus unpaid/advance vacation lines in booked runs; both read 0 for companies that never loaded categorized balances and outside the cutover year (unpaid days lapse at close, förskott is a one-time grant).
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Response fields
| Name | Type |
|---|---|
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 | object |
saved_days_total | number |
saved_days_taken | object |
unpaid_days | number |
advance_days | number |
forced_payout_days | number |
estimated_liability_sek | number |
Example response
{
"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"
}
}
GET /api/v1/companies/:companyId/employees/:id/worked-days
employees.worked-days.list · scope payroll:read
List worked days (hours per date) for an employee in a date range.
Returns the per-day worked-hours rows (tidrapport) between ?from and ?to (inclusive, max 92 days): hours, optional shift window (start_time/end_time) and notes. No cursor pagination: the bounded range is the page.
Use when: You need what is registered for an hourly employee before running payroll, to reconcile with an external time-tracking system, or to verify the hours the salary engine will pick up.
Don't use for: Absence (sick, vab, parental): GET /employees/{id}/absence. The derived pay (hourly gross, OB lines): that lives on the run after POST /salary-runs/{id}/calculate.
Pitfalls
- Ranges over 92 days return 400 VALIDATION_ERROR with details.max_days = 92: iterate quarters instead.
- POST /salary-runs/{id}/calculate reads these rows by the run's deviation window (deviation_period_start..deviation_period_end), not the pay month: register hours on the dates they were worked and check the run's window.
- One row per date: an hourly employee with two shifts on the same day has ONE row with the combined hours (and the shift window of the OB-relevant one).
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
from | string | yes | YYYY-MM-DD. First day of the range (inclusive). Required. |
to | string | yes | YYYY-MM-DD. Last day of the range (inclusive), not before from. Required. |
Response fields
| Name | Type |
|---|---|
[].salary_worked_day_id | string |
[].work_date | string |
[].hours | number |
[].start_time | string | null |
[].end_time | string | null |
[].notes | string | null |
[].created_at | string |
[].updated_at | string |
Example response
{
"data": [
{
"salary_worked_day_id": "wd_91d2…",
"work_date": "2026-03-02",
"hours": 8,
"start_time": "22:00:00",
"end_time": "06:00:00",
"notes": null
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/:companyId/salary-runs/:id/employees
salary-runs.employees.list · scope payroll:read
List per-employee results of a salary run.
Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).
Use when: You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.
Don't use for: Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.
Pitfalls
- Aggregates are 0 until POST /calculate has advanced the run to review.
- tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).
- personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Opaque cursor from the previous page's meta.next_cursor. Omit for the first page. |
limit | number | no | Page size, 1-100 (default 50). Larger values are clamped to 100. |
Response fields
| Name | Type |
|---|---|
[].salary_run_employee_id | string |
[].employee_id | string |
[].first_name | string |
[].last_name | string |
[].personnummer_masked | string |
[].salary_type | string |
[].employment_degree | number |
[].monthly_salary | number | null |
[].hours_worked | number | null |
[].gross_salary | number |
[].taxable_income | number |
[].tax_withheld | number |
[].tax_withheld_override | number | null |
[].net_salary | number |
[].avgifter_basis | number |
[].avgifter_amount | number |
[].avgifter_amount_override | number | null |
[].avgifter_category | string | null |
[].vacation_accrual | number |
[].sick_days | number |
[].vab_days | number |
[].parental_days | number |
[].vacation_days_taken | number |
[].created_at | string |
[].updated_at | string |
Example response
{
"data": [
{
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"salary_type": "monthly",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"avgifter_amount": 10997
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId
salary-runs.employees.get · scope payroll:read
Get one employee's payslip in a salary run.
Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.
Use when: You need to verify how a specific employee's pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.
Don't use for: The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.
Pitfalls
- calculation_breakdown is null and aggregates are 0 until POST /calculate has run.
- line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.
- The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.
- personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Response fields
| Name | Type |
|---|---|
salary_run_employee_id | string |
salary_run_id | string |
employee_id | string |
first_name | string |
last_name | string |
personnummer_masked | string |
salary_type | string |
employment_degree | number |
monthly_salary | number | null |
hours_worked | number | null |
gross_salary | number |
gross_deductions | number |
benefit_values | number |
taxable_income | number |
tax_withheld | number |
tax_withheld_override | number | null |
net_deductions | number |
net_salary | number |
avgifter_rate | number |
avgifter_basis | number |
avgifter_amount | number |
avgifter_basis_override | number | null |
avgifter_amount_override | number | null |
avgifter_category | string | null |
override_reason | string | null |
vacation_accrual | number |
vacation_accrual_avgifter | number |
tax_table_number | number | null |
tax_column | number | null |
tax_table_year | number | null |
sick_days | number |
vab_days | number |
parental_days | number |
vacation_days_taken | number |
ytd_gross | number |
ytd_tax | number |
ytd_net | number |
calculation_breakdown | unknown (optional) |
line_items | object[] |
created_at | string |
updated_at | string |
Example response
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"line_items": [
{
"salary_line_item_id": "sli_31c9…",
"item_type": "monthly_salary",
"description": "Grundlön",
"amount": 35000
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/employees
employees.create · scope payroll:write
Create an employee.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
first_name | string | yes |
last_name | string | yes |
personnummer | string | yes |
employment_type | "employee" | "company_owner" | "board_member" | no |
employment_start | string | yes |
employment_end | string | no |
employment_degree | number | no |
hours_per_week | number | no |
workdays_per_week | number | no |
salary_type | "monthly" | "hourly" | no |
monthly_salary | number | no |
hourly_rate | number | no |
tax_table_number | number | no |
tax_column | number | no |
tax_municipality | string | no |
is_sidoinkomst | boolean | no |
f_skatt_status | "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified" | no |
clearing_number | string | no |
bank_account_number | string | no |
vacation_rule | "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning" | no |
vacation_days_per_year | number | no |
semestertillagg_rate | number | no |
vacation_pay_rate | number | null | no |
email | string | no |
phone | string | no |
address_line1 | string | no |
postal_code | string | no |
city | string | no |
vaxa_stod_eligible | boolean | no |
vaxa_stod_start | string | no |
vaxa_stod_end | string | no |
jamkning_percentage | number | null | no |
jamkning_valid_from | string | null | no |
jamkning_valid_to | string | null | no |
default_dimensions | object | no |
Response fields
| Name | Type |
|---|---|
id | string |
first_name | string |
last_name | string |
personnummer_masked | string |
employment_type | "employee" | "company_owner" | "board_member" |
employment_start | string |
employment_end | string | null |
employment_degree | number |
salary_type | "monthly" | "hourly" |
monthly_salary | number | null |
hourly_rate | number | null |
tax_table_number | number | null |
tax_column | number | null |
tax_municipality | string | null |
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 |
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"
}
Example response
{
"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"
}
}
POST /api/v1/companies/:companyId/employees/:id/benefits
employees.benefits.create · scope payroll:write
Register a benefit (förmån) on an employee.
Creates a standing monthly förmånsvärde row. benefit_type is one of bike, car, meals, housing, wellness, other. Every type except bike takes monthly_value: the schablon value you already know. bike takes annual_market_value and the server derives monthly_value = max(0, annual_market_value - 3000) / 12 (Skatteverket schablon, 3 000 kr/year tax-free), storing the inputs in metadata. Mandatory Idempotency-Key. Dry-runnable: the preview is the row that would be inserted, with the derived values.
Use when: "Anna gets a company car from January": register the schablon value once and every run inside the window derives the line. Also when migrating an employee register from another payroll system.
Don't use for: Computing a bilförmån from the car (nybilspris, miljöbil, fordonsskatt): do that with Skatteverket's calculator and send the result. Standing deductions such as a bruttolöneavdrag for the same car: the recurring-lines register. One-off taxable additions: edit the payslip lines on the run.
Pitfalls
- The förmånsvärde is added to the employee's tax and arbetsgivaravgift basis when the run is calculated (POST /salary-runs/{id}/calculate): skatteavdrag and avgifter go up, nothing is paid out. Registering a benefit does not recompute an open run; call :calculate afterwards.
- car (bilförmån) is supplied as the monthly schablon value you computed (Skatteverket's bilförmånsberäkning, including miljöbil and 30 000 km reductions); the API does not compute it from the car.
- bike takes annual_market_value, not monthly_value: the server derives the monthly value with the 3 000 kr/year tax-free allowance. A monthly_value sent next to annual_market_value on a bike row is ignored.
- valid_from / valid_to gate which runs pick the row up: a run derives the line when valid_from <= payment_date <= valid_to (valid_to omitted = open-ended). Both dates are inclusive; valid_to before valid_from is 400 VALIDATION_ERROR.
- To stop a benefit that already fed a calculated run, PATCH is_active=false or set valid_to; DELETE on such a row keeps and deactivates it rather than removing it. Either way the derived line stays on a draft run until POST /salary-runs/{id}/calculate is called again, which drops it (#2695).
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
benefit_type | "bike" | "car" | "meals" | "housing" | "wellness" | "other" | yes |
description | string | yes |
monthly_value | number | no |
annual_market_value | number | no |
valid_from | string | yes |
valid_to | string | no |
metadata | object | no |
is_active | boolean | no |
Response fields
| Name | Type |
|---|---|
employee_benefit_id | string |
benefit_type | "bike" | "car" | "meals" | "housing" | "wellness" | "other" |
description | string |
monthly_value | number |
annual_market_value | number | null |
valid_from | string |
valid_to | string | null |
is_active | boolean |
metadata | object |
created_at | string |
updated_at | string |
Example request
{
"benefit_type": "bike",
"description": "Cykelförmån",
"annual_market_value": 15000,
"valid_from": "2026-03-01"
}
Example response
{
"data": {
"employee_benefit_id": "ben_91d2…",
"benefit_type": "bike",
"description": "Cykelförmån",
"monthly_value": 1000,
"annual_market_value": 15000,
"valid_from": "2026-03-01",
"valid_to": null,
"is_active": true,
"metadata": {
"annual_market_value": 15000,
"annual_taxable": 12000,
"tax_free_portion": 3000
},
"created_at": "2026-02-20T10:00:00Z",
"updated_at": "2026-02-20T10:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/employees/:id/recurring-lines
employees.recurring-lines.create · scope payroll:write
Create a recurring payslip line for an employee.
Adds a standing monthly payslip row. From the next :calculate on, every salary run whose payment_date falls inside valid_from..valid_to derives a payslip line from it, with flags (taxable, avgift basis, gross vs net deduction) fixed by item_type. Amounts are kept to whole öre. Requires an Idempotency-Key header.
Use when: An employee starts a benefit bike bruttolöneavdrag, a union fee, a monthly net deduction for a benefit they pay for, or any other deduction that repeats every month until further notice.
Don't use for: One-off deductions on a single payslip: add a line on the salary run instead. Additions (a monthly allowance paid in cash): not supported as recurring lines; add them per run. Taxable benefits in kind: use the employee benefits endpoints.
Pitfalls
- Rows are re-derived on every :calculate for runs whose payment_date falls inside valid_from..valid_to (valid_to null = open-ended). Hand edits to a derived payslip row are overwritten by the next :calculate.
- The amount sign follows the item type: every supported type is a deduction and must be negative (e.g. -670.17 for a benefit bike bruttolöneavdrag). The API rejects the wrong sign with 400 VALIDATION_ERROR on field amount.
- account_number overrides the default BAS account for the derived payslip row; null lets the engine use its item-type mapping.
- Draft-only per-run edits (a one-off change on one payslip) still go through the salary-runs lines endpoints, not through recurring lines.
- valid_to must be on or after valid_from (inclusive); omit it for an open-ended line.
- Creating a line does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
item_type | "gross_deduction_pension" | "gross_deduction_other" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | yes |
description | string | yes |
amount | number | yes |
account_number | string | no |
valid_from | string | yes |
valid_to | string | no |
metadata | object | no |
is_active | boolean | no |
Response fields
| Name | Type |
|---|---|
employee_recurring_line_id | string |
item_type | "gross_deduction_pension" | "gross_deduction_other" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" |
description | string |
amount | number |
account_number | string | null |
valid_from | string |
valid_to | string | null |
is_active | boolean |
metadata | object |
created_at | string |
updated_at | string |
Example request
{
"item_type": "gross_deduction_other",
"description": "Förmånscykel bruttolöneavdrag",
"amount": -670.17,
"valid_from": "2026-01-01"
}
Example response
{
"data": {
"employee_recurring_line_id": "erl_5b1c…",
"item_type": "gross_deduction_other",
"description": "Förmånscykel bruttolöneavdrag",
"amount": -670.17,
"account_number": null,
"valid_from": "2026-01-01",
"valid_to": null,
"is_active": true,
"metadata": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/salary-runs/:id/employees
salary-runs.employees.add · scope payroll:write
Add an employee to a draft salary run.
Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.
Use when: The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.
Don't use for: Changing an attached employee's pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.
Pitfalls
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.
- The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.
- Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
employee_id | string | yes |
hours_worked | number | no |
Response fields
| Name | Type |
|---|---|
salary_run_employee_id | string | null |
employee_id | string |
salary_type | string |
employment_degree | number |
monthly_salary | number |
hours_worked | number | null |
tax_table_number | number | null |
tax_column | number | null |
Example request
{
"employee_id": "emp_77b2…"
}
Example response
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"salary_type": "monthly",
"monthly_salary": 35000
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines
salary-runs.lines.create · scope payroll:write
Add a payslip line to an employee in a draft salary run.
Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre. one_off_tax_percent (engångsskatt) taxes the line at that verified flat percentage instead of the monthly table; allowed on a positive taxable bonus, commission, other, correction or semesterersattning line. A vacation line (item_type vacation, quantity = days) may carry vacation_category to say which pool the days come from: paid (Betalda, the default), extra_paid (Extra betalda), saved (Sparade, optionally one origin year in vacation_saved_year), unpaid (Obetalda) or advance (Förskott).
Use when: You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line. A bonus or final-settlement semesterersättning that Skatteverket taxes as an engångsbelopp: send one_off_tax_percent with the percentage you verified for the employee. Vacation days taken: an item_type vacation line with quantity = days and, when they are not this year's paid days, vacation_category.
Don't use for: Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.
Pitfalls
- Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.
- Engine-derived lines (absence, benefits, the semesterersättning row under vacation_rule semesterersattning) are regenerated on every :calculate; manual lines survive, including a semesterersattning line you add yourself.
- one_off_tax_percent is the percentage YOU verified against Skatteverket's engångsbelopp table for the employee's yearly income; the API never estimates it. It is refused (400) on deductions, benefits, non-taxable rows and non-positive amounts. A valid jämkning decision on the employee overrides it. Equal percentages are summed before the öre are dropped, so splitting one bonus over two rows never changes the withholding.
- vacation_category is only valid on item_type vacation (400 VALIDATION_ERROR otherwise) and vacation_saved_year only with category saved. Omitted category = paid. The vacation ledger splits the booked run's days by category: saved consumes the named origin year, or the oldest saved year first when omitted; unpaid and advance consume their own cutover pools.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
item_type | "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "expense_reimbursement" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other" | yes |
description | string | yes |
quantity | number | no |
unit_price | number | no |
amount | number | yes |
is_taxable | boolean | no |
is_avgift_basis | boolean | no |
is_vacation_basis | boolean | no |
is_gross_deduction | boolean | no |
is_net_deduction | boolean | no |
account_number | string | no |
sort_order | number | no |
one_off_tax_percent | number | null | no |
vacation_category | "paid" | "extra_paid" | "saved" | "unpaid" | "advance" | null | no |
vacation_saved_year | string | null | no |
Response fields
| Name | Type |
|---|---|
salary_line_item_id | string | null |
salary_run_employee_id | string |
item_type | string |
description | string |
quantity | number | null |
unit_price | number | null |
amount | number |
is_taxable | boolean |
is_avgift_basis | boolean |
is_vacation_basis | boolean |
is_gross_deduction | boolean |
is_net_deduction | boolean |
account_number | string | null |
sort_order | number |
one_off_tax_percent | number | null (optional) |
vacation_category | string | null (optional) |
vacation_saved_year | string | null (optional) |
Example request
{
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000,
"one_off_tax_percent": 30
}
Example response
{
"data": {
"salary_line_item_id": "sli_31c9…",
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000,
"account_number": "7210",
"one_off_tax_percent": 30
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/:companyId/employees/:id
employees.update · scope payroll:write
Update an employee.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
first_name | string | no |
last_name | string | no |
personnummer | string | no |
employment_type | "employee" | "company_owner" | "board_member" | no |
employment_start | string | no |
employment_end | string | no |
employment_degree | number | no |
hours_per_week | number | no |
workdays_per_week | number | no |
salary_type | "monthly" | "hourly" | no |
monthly_salary | number | no |
hourly_rate | number | no |
tax_table_number | number | no |
tax_column | number | no |
tax_municipality | string | no |
is_sidoinkomst | boolean | no |
f_skatt_status | "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified" | no |
clearing_number | string | no |
bank_account_number | string | no |
vacation_rule | "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning" | no |
vacation_days_per_year | number | no |
semestertillagg_rate | number | no |
vacation_pay_rate | number | null | no |
email | string | no |
phone | string | no |
address_line1 | string | no |
postal_code | string | no |
city | string | no |
vaxa_stod_eligible | boolean | no |
vaxa_stod_start | string | no |
vaxa_stod_end | string | no |
jamkning_percentage | number | null | no |
jamkning_valid_from | string | null | no |
jamkning_valid_to | string | null | no |
default_dimensions | object | no |
Response fields
| Name | Type |
|---|---|
id | string |
first_name | string |
last_name | string |
employment_type | "employee" | "company_owner" | "board_member" |
employment_start | string |
employment_end | string | null |
employment_degree | number |
hours_per_week | number |
workdays_per_week | number |
salary_type | "monthly" | "hourly" |
monthly_salary | number | null |
hourly_rate | number | null |
tax_table_number | number | null |
tax_column | number | null |
tax_municipality | string | null |
is_sidoinkomst | boolean |
f_skatt_status | "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified" |
clearing_number | string | null |
bank_account_number | string | null |
vacation_rule | string |
vacation_days_per_year | number |
semestertillagg_rate | number |
vacation_pay_rate | number | null |
email | string | null |
phone | string | null |
address_line1 | string | null |
postal_code | string | null |
city | string | null |
vaxa_stod_eligible | boolean |
vaxa_stod_start | string | null |
vaxa_stod_end | string | null |
jamkning_percentage | number | null |
jamkning_valid_from | string | null |
jamkning_valid_to | string | null |
default_dimensions | object |
is_active | boolean |
created_at | string |
updated_at | string |
personnummer_masked | string |
Example request
{
"monthly_salary": 38000,
"tax_municipality": "Göteborg"
}
Example response
{
"data": {
"id": "a8f1…",
"monthly_salary": 38000
}
}
PATCH /api/v1/companies/:companyId/employees/:id/benefits/:benefitId
employees.benefits.update · scope payroll:write
Partially update a benefit (förmån) on an employee.
Patches the supplied fields: description, monthly_value, valid_from, valid_to (null clears it), is_active, metadata, and for bike rows annual_market_value (the server re-derives monthly_value). benefit_type is not patchable: delete and recreate to change the kind. Mandatory Idempotency-Key. Dry-runnable: the preview is the merged row.
Use when: The förmånsvärde changes (new schablon for the year, a new bike price), the benefit ends (set valid_to), or you want to pause it without losing the row (is_active=false).
Don't use for: Changing the benefit kind (delete + create). Editing the derived line on one specific run: edit the payslip line on that run instead, the register stays as is.
Pitfalls
- Idempotency-Key is mandatory; calls without it return 400.
- valid_from and valid_to are checked against the MERGED stored+patched pair: a valid_to-only patch that predates the stored valid_from is 400 VALIDATION_ERROR (field valid_to).
- annual_market_value is accepted on bike rows only (400 otherwise) and overrides any monthly_value in the same body.
- The förmånsvärde is added to the tax and arbetsgivaravgift basis at :calculate; a change here does not recompute an open run. Call POST /salary-runs/{id}/calculate afterwards.
- To stop a benefit that a calculated run already consumed, set is_active=false or valid_to here; DELETE on such a row also keeps and deactivates it rather than removing it. Either way the derived line stays on a draft run until POST /salary-runs/{id}/calculate is called again, which drops it (#2695).
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
description | string | no |
monthly_value | number | no |
annual_market_value | number | no |
valid_from | string | no |
valid_to | string | null | no |
metadata | object | no |
is_active | boolean | no |
Response fields
| Name | Type |
|---|---|
employee_benefit_id | string |
benefit_type | "bike" | "car" | "meals" | "housing" | "wellness" | "other" |
description | string |
monthly_value | number |
annual_market_value | number | null |
valid_from | string |
valid_to | string | null |
is_active | boolean |
metadata | object |
created_at | string |
updated_at | string |
Example request
{
"valid_to": "2026-06-30"
}
Example response
{
"data": {
"employee_benefit_id": "ben_4f2a…",
"benefit_type": "car",
"description": "Bilförmån Volvo XC40",
"monthly_value": 4275,
"annual_market_value": null,
"valid_from": "2026-01-01",
"valid_to": "2026-06-30",
"is_active": true,
"metadata": {},
"created_at": "2026-01-05T09:12:00Z",
"updated_at": "2026-06-02T14:40:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/:companyId/employees/:id/recurring-lines/:lineId
employees.recurring-lines.update · scope payroll:write
Update a recurring payslip line.
Patches description, amount, account_number, valid_from, valid_to, is_active or metadata on a recurring line. The amount sign is re-checked against the stored item_type and the validity period against the merged (stored + patched) dates. item_type cannot change: delete and recreate instead. Requires an Idempotency-Key header.
Use when: The monthly deduction changed (new bike lease amount), the line ends on a known date (set valid_to), or it should pause without losing history (is_active=false).
Don't use for: Changing the kind of line (gross to net deduction): DELETE and POST a new one. Fixing one payslip only: edit the salary run line instead.
Pitfalls
- Rows are re-derived on every :calculate for runs whose payment_date falls inside valid_from..valid_to (valid_to null = open-ended). Hand edits to a derived payslip row are overwritten by the next :calculate.
- The amount sign follows the item type: every supported type is a deduction and must be negative (e.g. -670.17 for a benefit bike bruttolöneavdrag). The API rejects the wrong sign with 400 VALIDATION_ERROR on field amount.
- account_number overrides the default BAS account for the derived payslip row; null lets the engine use its item-type mapping.
- Draft-only per-run edits (a one-off change on one payslip) still go through the salary-runs lines endpoints, not through recurring lines.
- A patch that leaves valid_to before valid_from on the merged row is rejected with 400 VALIDATION_ERROR on field valid_to; send valid_to: null to make the line open-ended again.
- Runs already calculated keep their derived rows until they are recalculated; booked runs are never touched.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
description | string | no |
amount | number | no |
account_number | string | null | no |
valid_from | string | no |
valid_to | string | null | no |
metadata | object | no |
is_active | boolean | no |
Response fields
| Name | Type |
|---|---|
employee_recurring_line_id | string |
item_type | "gross_deduction_pension" | "gross_deduction_other" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" |
description | string |
amount | number |
account_number | string | null |
valid_from | string |
valid_to | string | null |
is_active | boolean |
metadata | object |
created_at | string |
updated_at | string |
Example request
{
"amount": -700,
"valid_to": "2026-12-31"
}
Example response
{
"data": {
"employee_recurring_line_id": "erl_5b1c…",
"item_type": "gross_deduction_other",
"description": "Förmånscykel bruttolöneavdrag",
"amount": -700,
"account_number": null,
"valid_from": "2026-01-01",
"valid_to": "2026-12-31",
"is_active": true,
"metadata": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId
salary-runs.employees.set-salary · scope payroll:write
Set this run's base salary for one employee.
Sets the per-run base salary (salary_run_employees.monthly_salary) that the calculation engine reads for this run. The employee master record is untouched, so each month's gross can differ from the employee's standard pay (variable owner salary). Draft-only; 0 is a valid nollkörning.
Use when: The employee's pay this month differs from their configured fixed salary: owners taking salary by need and capacity, one-off adjustments, or a deliberate zero month.
Don't use for: Changing the employee's standard salary going forward: PATCH /employees/{id}. Editing individual payslip lines (tillägg/avdrag): the lines endpoints. Tax/avgifter overrides in review: not exposed on v1 yet.
Pitfalls
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Run POST /calculate afterwards: gross, tax and totals reflect the new salary only after recalculation.
- Do NOT edit the monthly_salary line item instead: recalculation rebuilds base salary lines from this per-run value.
- For hourly employees the value is stored but gross derives from hours worked; the salary_type field in the response tells you which applies.
Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
monthly_salary | number | yes |
Response fields
| Name | Type |
|---|---|
salary_run_employee_id | string |
employee_id | string |
salary_type | string |
employment_degree | number |
previous_monthly_salary | number |
monthly_salary | number |
Example request
{
"monthly_salary": 45000
}
Example response
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"salary_type": "monthly",
"employment_degree": 100,
"previous_monthly_salary": 30000,
"monthly_salary": 45000
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/:companyId/employees/:id/absence
employees.absence.upsert · scope payroll:write
Register absence for an employee over a date range.
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).
Don't 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).
- Dates inside the avvikelseperiod (deviation window, deviation_period_start..deviation_period_end, NULL = the pay month) of a run that is already calculated (review), approved, paid or booked are locked: 409 SALARY_REGISTER_DATES_LOCKED_BY_RUN naming the run (details.salary_run_id, details.status, details.locked_dates), nothing written, dry runs included. The way out is to revert that run to draft (dashboard) or, for a booked run, POST /salary-runs/{id}/correct and register the days against the correction run. Draft runs never lock.
- Registering absence does not recompute a draft salary run: call POST /salary-runs/{id}/calculate afterwards.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
from | string | yes |
to | string | yes |
absence_type | "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave" | yes |
hours_per_day | number | no |
notes | string | no |
include_weekends | boolean | no |
Response fields
| Name | Type |
|---|---|
count | number |
days | object[] |
Example request
{
"from": "2026-03-03",
"to": "2026-03-07",
"absence_type": "sick"
}
Example response
{
"data": {
"count": 5,
"days": [
{
"absence_date": "2026-03-03",
"absence_type": "sick",
"hours": 8
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/:companyId/employees/:id/opening-balances
employees.opening-balances.set · scope payroll:write
Set an employee's payroll cutover opening balances.
Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, the vacation pools in the previous system's own terms (vacation_paid_days_remaining = Betalda, vacation_saved_days_by_year = Sparade per år, vacation_unpaid_days_remaining = Obetalda, vacation_advance_days_remaining = Förskott, vacation_extra_paid_days_remaining = Extra betalda), paid days already taken this vacation year, vacation_as_of_date (the day those pools are struck per), opening semesterlöneskuld SEK (+avgifter), opening_advance_vacation_debt (förskottsskuld SEK), 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/Azets/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.
Don't 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) and an omitted vacation_as_of_date resets to null. Send the complete state every time.
- vacation_as_of_date defaults to the day before cutover_date. Booked runs whose avvikelseperiod ends on or before it are treated as already inside the balance and not deducted again, so with salary_deviation_period = previous_month send the last day BEFORE the month the first run deducts (cutover 2026-09-01, first run deducts August: send 2026-07-31) or August's leave is never deducted.
- ytd_net: send null when the previous system cannot export historical net pay; the payslip prints "Underlag saknas" instead of a false 0. Never send gross minus tax as net.
- 409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.
- The opening liability and the förskottsskuld are NOT booked by Accounted: they only feed the vacation-liability report (the förskottsskuld as its own row, subtracted from the net liability).
- Extra betalda join the paid pool: the ledger's entitled days = Betalda + Extra betalda + days already taken. Obetalda lapse at the vacation-year close; Förskott days taken reduce the next year's entitlement.
- YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.
Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
cutover_date | string | yes |
ytd_gross | number | no |
ytd_tax | number | no |
ytd_net | number | null | no |
vacation_paid_days_remaining | number | no |
vacation_days_taken_this_year | number | no |
vacation_saved_days_by_year | object | no |
opening_semester_liability | number | no |
opening_semester_liability_avgifter | number | no |
karens_periods_adjustment | number | no |
vacation_as_of_date | string | null | no |
vacation_unpaid_days_remaining | number | no |
vacation_advance_days_remaining | number | no |
vacation_extra_paid_days_remaining | number | no |
opening_advance_vacation_debt | number | no |
Response fields
| Name | Type |
|---|---|
employee_opening_balances_id | string | null |
employee_id | string |
cutover_date | string |
ytd_gross | number |
ytd_tax | number |
ytd_net | number | null |
vacation_paid_days_remaining | number |
vacation_days_taken_this_year | number |
vacation_saved_days_by_year | object |
opening_semester_liability | number |
opening_semester_liability_avgifter | number |
karens_periods_adjustment | number |
vacation_as_of_date | string | null |
vacation_unpaid_days_remaining | number |
vacation_advance_days_remaining | number |
vacation_extra_paid_days_remaining | number |
opening_advance_vacation_debt | number |
locked | boolean |
locked_by_run_id | string | null |
Example request
{
"cutover_date": "2026-09-01",
"ytd_gross": 280000,
"ytd_tax": 64000,
"ytd_net": 216000,
"vacation_as_of_date": "2026-07-31",
"vacation_paid_days_remaining": 12.5,
"vacation_days_taken_this_year": 10,
"vacation_extra_paid_days_remaining": 2,
"vacation_saved_days_by_year": {
"2025": 5
},
"vacation_unpaid_days_remaining": 0,
"vacation_advance_days_remaining": 3,
"opening_semester_liability": 42000,
"opening_semester_liability_avgifter": 13196.4,
"opening_advance_vacation_debt": 4500,
"karens_periods_adjustment": 1
}
Example response
{
"data": {
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/:companyId/employees/:id/worked-days
employees.worked-days.upsert · scope payroll:write
Register worked hours per day for an employee (bulk upsert).
Upserts 1..92 explicit per-day rows on the natural key (employee, work_date) in one atomic statement. A date already registered is overwritten with the new hours, shift window and notes (omitted optional fields are cleared, not carried forward). Idempotent by construction: replaying the same PUT converges on the same rows. Each work_date may appear once per request.
Use when: An external time-tracking or payroll system pushes an hourly employee's tidrapport for a period, including shift start/end times for OB (obekväm arbetstid) premiums, before the salary run is calculated.
Don't use for: Absence: PUT /employees/{id}/absence. Monthly-salaried staff without OB rules: their gross comes from the employee profile, not from this register.
Pitfalls
- POST /salary-runs/{id}/calculate reads these rows by the run's deviation window (deviation_period_start..deviation_period_end), not the pay month: register the hours on the dates they were actually worked, and check the run's window before calculating.
- For hourly employees the run's gross is derived from these rows (hourly_rate x sum(hours)): PATCH /salary-runs/{id}/employees/{employeeId} monthly_salary is irrelevant for them.
- start_time/end_time feed the OB/shift-premium rules: a row without them is priced as an assumed 08:00-17:00 day, so a night or weekend shift earns no premium. Times are HH:MM or HH:MM:SS; end_time before start_time means the shift crosses midnight.
- Worked hours plus absence hours on one date may not exceed 24 (DB trigger, shared with absence): the whole PUT is rejected with 409 ABSENCE_HOURS_CONFLICT, nothing is written.
- Dates inside the avvikelseperiod (deviation window, deviation_period_start..deviation_period_end, NULL = the pay month) of a run that is already calculated (review), approved, paid or booked are locked: 409 SALARY_REGISTER_DATES_LOCKED_BY_RUN naming the run (details.salary_run_id, details.status, details.locked_dates), nothing written, dry runs included. The way out is to revert that run to draft (dashboard) or, for a booked run, POST /salary-runs/{id}/correct and register the days against the correction run. Draft runs never lock.
- Registering hours does not recompute a draft salary run: call POST /salary-runs/{id}/calculate afterwards.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
days | object[] | yes |
Response fields
| Name | Type |
|---|---|
count | number |
days | object[] |
Example request
{
"days": [
{
"work_date": "2026-03-02",
"hours": 8,
"start_time": "22:00",
"end_time": "06:00"
},
{
"work_date": "2026-03-03",
"hours": 4,
"notes": "Halvdag"
}
]
}
Example response
{
"data": {
"count": 2,
"days": [
{
"salary_worked_day_id": "wd_91d2…",
"work_date": "2026-03-02",
"hours": 8,
"start_time": "22:00:00",
"end_time": "06:00:00",
"notes": null
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PUT /api/v1/companies/:companyId/employees/opening-balances
employees.opening-balances.bulk-set · scope payroll:write
Bulk-set payroll cutover opening balances (atomic).
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.
Don't 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 (vacation_as_of_date to null).
- Duplicate employee_id within items is rejected outright.
- Vacation pools map one to one onto Fortnox/Azets: vacation_paid_days_remaining = Betalda, vacation_saved_days_by_year = Sparade per år, vacation_unpaid_days_remaining = Obetalda, vacation_advance_days_remaining = Förskott, vacation_extra_paid_days_remaining = Extra betalda; opening_advance_vacation_debt is the förskottsskuld in SEK.
- vacation_as_of_date is the day the pools are struck per (default: the day before cutover_date). Under salary_deviation_period = previous_month the first run deducts the month before cutover, so send the last day before that month or its leave is treated as already deducted.
- ytd_net: null when the previous system cannot export historical net pay (payslip prints "Underlag saknas"); never gross minus tax.
Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Request body
| Name | Type | Required |
|---|---|---|
items | object[] | yes |
Response fields
| Name | Type |
|---|---|
count | number |
rows | object[] |
Example request
{
"items": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-09-01",
"ytd_gross": 280000,
"ytd_tax": 64000,
"ytd_net": null,
"vacation_as_of_date": "2026-07-31",
"vacation_paid_days_remaining": 12.5,
"vacation_days_taken_this_year": 10,
"vacation_saved_days_by_year": {
"2025": 5
},
"vacation_unpaid_days_remaining": 0,
"vacation_advance_days_remaining": 3,
"vacation_extra_paid_days_remaining": 2,
"opening_advance_vacation_debt": 4500
}
]
}
Example response
{
"data": {
"count": 1,
"rows": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/employees/:id
employees.delete · scope payroll:write
Soft-delete an employee.
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.
Don't 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.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Example response
{
"data": null
}
DELETE /api/v1/companies/:companyId/employees/:id/absence
employees.absence.delete · scope payroll:write
Delete absence days for an employee in a date range.
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".
Don't use for: Correcting hours on a day: PUT the day again instead. Rows a calculated, approved, paid or booked run has already read: the delete is refused (409 SALARY_REGISTER_DATES_LOCKED_BY_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.
- Dates inside the avvikelseperiod (deviation window, deviation_period_start..deviation_period_end, NULL = the pay month) of a run that is already calculated (review), approved, paid or booked are locked: 409 SALARY_REGISTER_DATES_LOCKED_BY_RUN naming the run (details.salary_run_id, details.status, details.locked_dates), nothing written, dry runs included. The way out is to revert that run to draft (dashboard) or, for a booked run, POST /salary-runs/{id}/correct and register the days against the correction run. Draft runs never lock.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
from | string | yes | YYYY-MM-DD. First day of the range (inclusive). Required. |
to | string | yes | YYYY-MM-DD. Last day of the range (inclusive), not before from. Required. |
type | "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave" | no | Only days of this absence type. Default: every type. |
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Response fields
| Name | Type |
|---|---|
deleted_count | number |
Example response
{
"data": {
"deleted_count": 2
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/employees/:id/benefits/:benefitId
employees.benefits.delete · scope payroll:write
Remove a benefit (förmån) from an employee.
Removes the benefit from the employee, the same operation the dashboard performs. A row that no payslip line derives from is hard-deleted; a row that a calculated run already derived a line from is kept and switched off (is_active=false) so the line keeps its provenance. Answers 200 with the outcome, 404 NOT_FOUND when no such row exists on the employee. Mandatory Idempotency-Key. Dry-runnable: the preview is the row that would be removed.
Use when: A benefit was registered by mistake, or it ends and you do not need it listed as active any more. If it has been used by a calculated run it is deactivated rather than deleted.
Don't use for: Ending a benefit on a date while keeping it active until then: PATCH valid_to. Removing the derived line from one run: edit that run's payslip lines.
Pitfalls
- Idempotency-Key is mandatory.
- Answers 200 with { employee_benefit_id, deleted, deactivated }. A benefit that a payslip line already derives from is never hard-deleted: it is kept and switched off (deleted=false, deactivated=true), so the chain from a booked verifikat back to its förmån stays intact (BFL 5 kap 6-7 §). A second DELETE of a gone id returns 404 NOT_FOUND; a second DELETE of a deactivated row answers deactivated=true again.
- Removing or deactivating a benefit does not recompute an open run: the derived line stays on a draft run until POST /salary-runs/{id}/calculate is called again, which drops it (#2695). A booked run is never changed.
Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Response fields
| Name | Type |
|---|---|
employee_benefit_id | string |
deleted | boolean |
deactivated | boolean |
Example response
{
"data": {
"employee_benefit_id": "ben_9c2e…",
"deleted": true,
"deactivated": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/employees/:id/recurring-lines/:lineId
employees.recurring-lines.delete · scope payroll:write
Delete a recurring payslip line, or deactivate it if a run already used it.
Removes the line when no salary run has derived a payslip row from it. Once a run has (the derived row references the line), the database refuses the delete and the line is deactivated instead (is_active=false): the payslip row keeps its provenance, the next :calculate of a draft run drops the derived row, and nothing is re-derived. Returns 200 with deleted: true or deleted: false + deactivated: true so the caller knows which happened. Requires an Idempotency-Key header.
Use when: The deduction ends and there is no end date to keep (a union fee stops, the bike lease is returned), or the line was created by mistake.
Don't use for: Ending a line on a future date: PATCH valid_to instead, so the remaining months still derive. Removing a derived row from one draft payslip: DELETE the salary run line.
Pitfalls
- Rows are re-derived on every :calculate for runs whose payment_date falls inside valid_from..valid_to (valid_to null = open-ended). Hand edits to a derived payslip row are overwritten by the next :calculate.
- The amount sign follows the item type: every supported type is a deduction and must be negative (e.g. -670.17 for a benefit bike bruttolöneavdrag). The API rejects the wrong sign with 400 VALIDATION_ERROR on field amount.
- account_number overrides the default BAS account for the derived payslip row; null lets the engine use its item-type mapping.
- Draft-only per-run edits (a one-off change on one payslip) still go through the salary-runs lines endpoints, not through recurring lines.
- deleted: false with deactivated: true is a success, not an error: a run already derived from the line, so it is kept for history and switched off.
- A lineId under another employee or company answers 404 NOT_FOUND.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Response fields
| Name | Type |
|---|---|
employee_recurring_line_id | string |
deleted | boolean |
deactivated | true (optional) |
Example response
{
"data": {
"employee_recurring_line_id": "erl_5b1c…",
"deleted": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/employees/:id/worked-days
employees.worked-days.delete · scope payroll:write
Delete worked days for an employee in a date range.
Deletes the per-day worked-hours rows between ?from and ?to (inclusive). Returns deleted_count (200, not 204) so callers can verify how many rows went. Single day = from == to.
Use when: Hours were pushed for the wrong employee or the wrong dates, or a time-tracking re-sync needs a clean period before a fresh PUT.
Don't use for: Correcting hours on a day: PUT the day again instead. Rows a calculated, approved, paid or booked run has already read: the delete is refused (409 SALARY_REGISTER_DATES_LOCKED_BY_RUN); use the run correction flow.
Pitfalls
- deleted_count: 0 with a 200 means nothing matched: not an error.
- Dates inside the avvikelseperiod (deviation window, deviation_period_start..deviation_period_end, NULL = the pay month) of a run that is already calculated (review), approved, paid or booked are locked: 409 SALARY_REGISTER_DATES_LOCKED_BY_RUN naming the run (details.salary_run_id, details.status, details.locked_dates), nothing written, dry runs included. The way out is to revert that run to draft (dashboard) or, for a booked run, POST /salary-runs/{id}/correct and register the days against the correction run. Draft runs never lock.
- Hours a draft run has already summed stay in the run until POST /salary-runs/{id}/calculate is called again.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
from | string | yes | YYYY-MM-DD. First day of the range (inclusive). Required. |
to | string | yes | YYYY-MM-DD. Last day of the range (inclusive), not before from. Required. |
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Response fields
| Name | Type |
|---|---|
deleted_count | number |
Example response
{
"data": {
"deleted_count": 2
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId
salary-runs.employees.remove · scope payroll:write
Remove an employee from a draft salary run.
Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.
Use when: An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.
Don't use for: Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.
Pitfalls
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Cascade-deletes the employee's line items in this run, including manual ones.
- Re-attaching later retakes the pay snapshot from the employee master.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
dry_run | string | no | true (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits. |
Example response
{
"data": null
}