Menu

Salary runs

Payroll lifecycle: create, calculate, approve, mark paid, book, generate AGI XML, and close the vacation year.

Endpoints


GET /api/v1/companies/:companyId/salary-runs

salary-runs.list · scope payroll:read

List salary runs.

Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft.

Use when: You need an overview of payroll activity: for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb.

Don't use for: Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3).

Pitfalls

  • A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer.
  • Totals are denormalised: they are 0 until POST /calculate runs.
  • corrected status is reached via the internal /correct route (not yet exposed on v1): Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Query parameters

NameTypeRequiredDescription
period_yearnumbernoOnly runs for this payroll year (2020-2100).
status"draft" | "review" | "approved" | "paid" | "booked" | "corrected"noOnly runs in this status.
cursorstringnoOpaque cursor from the previous page's meta.next_cursor. Omit for the first page.
limitnumbernoPage size, 1-100 (default 50). Larger values are clamped to 100.

Response fields

NameType
[].idstring
[].period_yearnumber
[].period_monthnumber
[].payment_datestring
[].deviation_period_startstring | null
[].deviation_period_endstring | null
[].status"draft" | "review" | "approved" | "paid" | "booked" | "corrected"
[].voucher_seriesstring
[].total_grossnumber
[].total_taxnumber
[].total_netnumber
[].total_avgifternumber
[].total_employer_costnumber
[].agi_generated_atstring | null
[].agi_submitted_atstring | null
[].approved_atstring | null
[].paid_atstring | null
[].booked_atstring | null
[].created_atstring

Example response

{
  "data": [
    {
      "id": "run_a8f1…",
      "period_year": 2026,
      "period_month": 5,
      "payment_date": "2026-05-25",
      "status": "draft",
      "voucher_series": "A",
      "total_gross": 0,
      "total_tax": 0,
      "total_net": 0,
      "total_avgifter": 0,
      "total_employer_cost": 0
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

GET /api/v1/companies/:companyId/salary-runs/:id

salary-runs.get · scope payroll:read

Get a salary run.

Returns the salary run's lifecycle state, denormalised totals (gross/tax/net/avgifter/vacation/employer_cost), and references to the journal entries it produced (once :book has run).

Use when: You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.

Don't use for: Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.

Pitfalls

  • salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.
  • total_* fields are 0 until POST /calculate has run.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Response fields

NameType
idstring
period_yearnumber
period_monthnumber
payment_datestring
deviation_period_startstring | null
deviation_period_endstring | null
status"draft" | "review" | "approved" | "paid" | "booked" | "corrected"
voucher_seriesstring
total_grossnumber
total_taxnumber
total_netnumber
total_avgifternumber
total_vacation_accrualnumber
total_employer_costnumber
salary_entry_idstring | null
avgifter_entry_idstring | null
vacation_entry_idstring | null
agi_generated_atstring | null
agi_submitted_atstring | null
calculation_paramsunknown (optional)
approved_bystring | null
approved_atstring | null
paid_atstring | null
booked_atstring | null
booked_bystring | null
notesstring | null
created_atstring
updated_atstring

Example response

{
  "data": {
    "id": "run_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "status": "approved",
    "total_gross": 105000,
    "total_tax": -28500,
    "total_net": 76500,
    "total_avgifter": 32991,
    "total_employer_cost": 137991
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

GET /api/v1/companies/:companyId/salary-runs/:id/payment-files

salary-runs.payment-files.list · scope payroll:read

List the archived bank payment files of a salary run.

Returns every payment file generated for the run (ISO 20022 pain.001 or Bankgirot LB), newest first, with the file content inline. Each row is an immutable archive copy written when the file was generated (BFL 7 kap. 1 §, seven-year retention): what was handed to the bank, byte for byte. sha256 and byte_size are over content encoded as charset (UTF-8 for pain001, ISO 8859-1 for bg_lb). Cursor pagination on (generated_at, id), newest first.

Use when: You need the file that was actually generated earlier (to re-upload, to verify a checksum against the bank portal, or to audit what the bank received) rather than a fresh build from the run's current data.

Don't use for: Generating a file: use POST /salary-runs/{id}/payment-file. Marking the run paid (:mark-paid) or booking it (:book). Supplier payment batches: use the supplier-invoice payment batch endpoints.

Pitfalls

  • An empty list means no file has been generated for the run yet (or the run predates the archive): generate one with POST /salary-runs/{id}/payment-file.
  • Rows are immutable and never deleted; a regeneration adds a new row. The newest row is not necessarily the one uploaded to the bank: compare sha256 with the checksum of the file you actually sent.
  • Write content to disk in charset (pain001 as UTF-8, bg_lb as ISO 8859-1 with CRLF line endings, exactly as returned); sha256 and byte_size describe those bytes, not the JSON string.
  • Every row carries the full file content, so page size matters for runs with many regenerations: use limit and the cursor.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Query parameters

NameTypeRequiredDescription
cursorstringnoOpaque cursor from the previous page's meta.next_cursor. Omit for the first page.
limitnumbernoPage size, 1-100 (default 50). Larger values are clamped to 100.

Response fields

NameType
[].payment_file_idstring
[].format"pain001" | "bg_lb"
[].filenamestring
[].content_type"application/xml" | "text/plain"
[].charset"utf-8" | "iso-8859-1"
[].sha256string
[].byte_sizenumber
[].payment_datestring
[].employee_countnumber
[].total_amountnumber
[].generated_atstring
[].contentstring

Example response

{
  "data": [
    {
      "payment_file_id": "f0f0f0f0-f0f0-4f0f-8f0f-f0f0f0f0f0f0",
      "format": "pain001",
      "filename": "pain001_lon_2026-05.xml",
      "content_type": "application/xml",
      "charset": "utf-8",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "byte_size": 2731,
      "payment_date": "2026-05-25",
      "employee_count": 3,
      "total_amount": 76500,
      "generated_at": "2026-05-20T08:00:00.000Z",
      "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03\"><CstmrCdtTrfInitn>…</CstmrCdtTrfInitn></Document>"
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf

salary-runs.payslip.pdf · scope payroll:read

Download one employee's payslip as PDF.

Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.

Use when: You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.

Don't use for: The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: POST /salary-runs/{id}/send-payslips sends each a secure link.

Pitfalls

  • The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.
  • PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

{
  "_note": "Returns application/pdf binary stream."
}

GET /api/v1/companies/:companyId/salary/settings

salary.settings.get · scope payroll:read

Get the company payroll settings.

Returns the payroll settings that drive new salary runs: pay day (salary_pay_day), avvikelseperiod (salary_deviation_period: which month a run reads absence and worked days from), salary payment file format (preferred_payment_format), the bank whose upload instructions are pre-selected (salary_default_bank), öresavrundning of net pay (salary_net_rounding), the calculation conventions (salary_calculation_policy: partial_month, sick_rate, long_leave, leave_context, net_rounding, one_off_tax_rounding, every key always present) and the voucher series salary runs book into (salary_voucher_series). A company that has no settings row yet answers with the defaults the engine would apply (pay day 25, same_month, pain001, no bank, no rounding, every convention at its default, series A).

Use when: You are provisioning or auditing a customer for payroll and need to know how new salary runs will be dated, which month their deviations are read from, which calculation conventions the engine applies, which payment file the bank expects, or which voucher series the salary vouchers land in.

Don't use for: Invoice payment and contact details (PATCH /api/v1/companies/{companyId}/settings). Per-run values such as payment_date or deviation window (GET /salary-runs/{id}: they are snapshotted on the run). The conventions a calculated run actually used (GET /salary-runs/{id}: calculation_params.salary_calculation_policy). Employee-level pay settings (GET /employees/{id}).

Pitfalls

  • salary_deviation_period is snapshotted onto each salary run at creation: changing it never moves a run that already exists. Set it before the first run of a new month. Switching later makes the next run's deviation window overlap the previous run's window, and that run is refused with 409 SALARY_RUN_DEVIATION_PERIOD_OVERLAP (pass explicit deviation_period_start/end on that one run to bridge the switch).
  • salary_pay_day only drives the default payment_date of NEW runs (the day of the pay month, 1-28 so it exists in every month). Existing runs keep their payment_date; override per run on POST /salary-runs.
  • salary_voucher_series is an alias for company_settings.default_voucher_series_per_source_type.salary_payment. Writes MERGE that one key into the per-source-type map; the other source types keep their letters. The default company layout books salaries on K.
  • preferred_payment_format: pain001 (ISO 20022) is the default; bg_lb (Bankgirot Leverantörsbetalningar / Lön) is being retired by the banks during 2026, so only pick it for a customer whose bank still accepts LB files.
  • salary_calculation_policy holds the company's calculation conventions (beräkningsprinciper). Every key defaults to the historical Accounted behaviour; a customer migrated from Fortnox usually wants partial_month=annual_calendar_days (månadslön × 12 / 365 per calendar day employed), sick_rate=annual_hourly (timlön = månadslön × 12 / (52 × veckoarbetstid) for sjuklön), long_leave=calendar_after_five_workdays (leave longer than five working days deducted per calendar day at månadslön × 12 / 365, a whole month = the monthly salary) and, with salary_net_rounding, net_rounding=nearest. Compare one historical payslip before switching.
  • A PATCH of salary_calculation_policy is merged key by key into the stored policy (omitted keys keep their value); the response and the stored value always carry all six keys. It is not snapshotted onto existing runs at creation: the conventions are read at :calculate and frozen into the run's calculation_params, so a draft recalculated after a change follows the new conventions and a calculated run does not.
  • long_leave=calendar_after_five_workdays is a five-day-week rule: :calculate refuses (400 VALIDATION_ERROR) a monthly employee whose workdays_per_week is not 5 while it is on. leave_context only matters under that convention.
  • one_off_tax_rounding governs engångsskatt on payslip lines that carry one_off_tax_percent (POST /salary-runs/{id}/employees/{employeeId}/lines); truncate (öretal bortfaller) is the statutory rule, nearest exists to reproduce another system's history.
  • A company without a settings row reports series A (the engine fallback). The first PATCH creates the row with the standard series set, where salary_payment is K, unless salary_voucher_series is supplied in that same call: send it explicitly when provisioning so the letter never changes under you.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Response fields

NameType
company_idstring
salary_pay_daynumber
salary_deviation_period"same_month" | "previous_month"
preferred_payment_format"pain001" | "bg_lb"
salary_default_bank"swedbank" | "seb" | "handelsbanken" | "nordea" | "other" | null
salary_net_roundingboolean
salary_calculation_policyobject
salary_voucher_seriesstring

Example response

{
  "data": {
    "company_id": "aaaa1111-2222-4333-8444-555566667777",
    "salary_pay_day": 25,
    "salary_deviation_period": "previous_month",
    "preferred_payment_format": "pain001",
    "salary_default_bank": "swedbank",
    "salary_net_rounding": true,
    "salary_calculation_policy": {
      "partial_month": "annual_calendar_days",
      "sick_rate": "annual_hourly",
      "long_leave": "calendar_after_five_workdays",
      "leave_context": "all_registered",
      "net_rounding": "nearest",
      "one_off_tax_rounding": "truncate"
    },
    "salary_voucher_series": "K"
  },
  "meta": {
    "request_id": "req_...",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs

salary-runs.create · scope payroll:write

Create a salary run.

Creates a draft salary run for the given period (period_year, period_month). The run starts empty: add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable.

Use when: You are starting a new month's payroll. Use dry-run first to validate the period + voucher_series choice without committing.

Don't use for: Adding employees to an existing run (POST /salary-runs/{id}/employees).

Pitfalls

  • Idempotency-Key is mandatory.
  • Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.
  • Avvikelseperiod: absence and worked days are read from deviation_period_start..deviation_period_end, NOT necessarily from the pay month. Omit both to use the company setting (salary_deviation_period: same_month by default, previous_month for "innevarande månads lön, föregående månads avvikelser"), or pass both explicitly. A window that overlaps another live run returns 409 SALARY_RUN_DEVIATION_PERIOD_OVERLAP (the same day would be deducted twice); one date without the other, or a span over 62 days, returns 400 SALARY_RUN_DEVIATION_PERIOD_INVALID.
  • period_month is 1-12. The DB CHECK enforces this: a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB.
  • voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly.
  • A newly-created run has no employees: :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
period_yearnumberyes
period_monthnumberyes
payment_datestringyes
voucher_seriesstringno
notesstringno
deviation_period_startstringno
deviation_period_endstringno

Response fields

NameType
idstring
period_yearnumber
period_monthnumber
payment_datestring
deviation_period_startstring | null
deviation_period_endstring | null
status"draft" | "review" | "approved" | "paid" | "booked" | "corrected"
voucher_seriesstring
total_grossnumber
total_taxnumber
total_netnumber
total_avgifternumber
total_employer_costnumber
agi_generated_atstring | null
agi_submitted_atstring | null
approved_atstring | null
paid_atstring | null
booked_atstring | null
created_atstring
notesstring | null
calculation_paramsunknown (optional)
updated_atstring

Example request

{
  "period_year": 2026,
  "period_month": 5,
  "payment_date": "2026-05-25",
  "voucher_series": "L",
  "deviation_period_start": "2026-04-01",
  "deviation_period_end": "2026-04-30"
}

Example response

{
  "data": {
    "id": "run_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "payment_date": "2026-05-25",
    "deviation_period_start": "2026-04-01",
    "deviation_period_end": "2026-04-30",
    "status": "draft",
    "voucher_series": "L"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/approve

salary-runs.approve · scope payroll:write

Approve a reviewed salary run.

Advances a salary run from review to approved after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (calculation_breakdown proves :calculate ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one.

Use when: You have a salary run in review status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won't exist until :book runs.

Don't use for: Posting journal entries (use :book after :mark-paid). Reverting an approval (POST /salary-runs/{id}/unapprove while the run is unpaid and its AGI unfiled; call :correct once the run is booked).

Pitfalls

  • Run must be in review: non-review runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW.
  • Every employee on the run needs a clearing_number + bank_account_number that name a payable account (clearing 4 digits, or 5 starting with 8; account 5-10 digits without the clearing number). Missing or invalid bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list; the list names employees, never account numbers.
  • Every employee on the run needs calculation_breakdown populated. If you skipped :calculate somehow, approve fails.
  • Employees without email get a non-blocking warning (lönebesked can't be sent automatically).
  • No period-lock check here: that lives on :book where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; :book will later refuse.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
idstring
status"approved"
approved_atstring
approved_bystring | null
warningsstring[]

Example response

{
  "data": {
    "id": "run_a8f1…",
    "status": "approved",
    "approved_at": "2026-05-14T12:00:00Z",
    "approved_by": "user_b73c…",
    "warnings": [
      "Anna Andersson: E-post saknas, lönebesked kan inte skickas"
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/book

salary-runs.book · scope payroll:write

Post the verifikationer for a paid salary run.

Creates 2-4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status paid → booked with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip: the run stays in paid so the caller can fix the cause (locked period, missing BAS account, etc.) and retry.

Use when: You've marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) :correct verb.

Don't use for: Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID).

Pitfalls

  • Run must be in paid: non-paid runs return 400 SALARY_RUN_BOOK_NOT_PAID.
  • payment_date must fall in an open fiscal period: locked period returns 400 PERIOD_LOCKED with fiscal_period_id and a hint of what unlock action is needed.
  • BFL 5 kap immutability: once :book succeeds the verifikationer cannot be edited or deleted. Corrections require :correct (Phase 5 PR-3) which does a storno-then-rebook.
  • The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as entry_ids).
  • Strict-mode: if the engine fails partway, the salary_runs row stays in paid. There is no "partial booking": the engine either commits all entries or the entire booking fails.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
idstring
status"booked"
booked_atstring
booked_bystring | null
salary_entry_idstring
avgifter_entry_idstring
vacation_entry_idstring | null
pension_entry_idstring | null
entry_idsstring[]

Example response

{
  "data": {
    "id": "run_a8f1…",
    "status": "booked",
    "booked_at": "2026-05-26T09:15:00Z",
    "booked_by": "user_b73c…",
    "salary_entry_id": "je_salary…",
    "avgifter_entry_id": "je_avg…",
    "vacation_entry_id": "je_vac…",
    "pension_entry_id": null,
    "entry_ids": [
      "je_salary…",
      "je_avg…",
      "je_vac…"
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "audit": {
      "voucher_number": "L2026-0023",
      "voucher_url": "/api/v1/companies/.../journal-entries/je_salary…",
      "immutable_at": "2026-05-26T09:15:00Z"
    }
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/calculate

salary-runs.calculate · scope payroll:write

Calculate a draft salary run and advance it to review.

Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a warnings array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip: the run stays in draft.

Use when: You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run.

Don't use for: re-running a salary run already in review or later (only draft is accepted: send a review run back with POST /salary-runs/{id}/revert, recall an approval with POST /salary-runs/{id}/unapprove first, and revise a paid or booked run with POST /salary-runs/{id}/correct). Adding employees to the run (POST /salary-runs/{id}/employees).

Pitfalls

  • Run must be in draft status: calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT.
  • Salary run must have at least one employee: empty runs return 400 SALARY_RUN_NO_EMPLOYEES.
  • If Skatteverket's tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level.
  • F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified.
  • Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
idstring
status"review"
period_yearnumber
period_monthnumber
total_grossnumber
total_taxnumber
total_netnumber
total_avgifternumber
total_employer_costnumber
warningsstring[]

Example response

{
  "data": {
    "id": "run_a8f1…",
    "status": "review",
    "period_year": 2026,
    "period_month": 5,
    "total_gross": 105000,
    "total_tax": 28500,
    "total_net": 76500,
    "total_avgifter": 32991,
    "total_employer_cost": 137991,
    "warnings": [
      "Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns."
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/correct

salary-runs.correct · scope payroll:write

Correct a booked salary run (rättelsekörning): storno its verifikat and open a new draft for the same period.

Per Bokföringslagen 5 kap 5 § a booked salary run is never edited: this verb reverses every verifikation the run posted (salary, arbetsgivaravgifter, semesterlöneskuld, pension) with storno entries, marks the original corrected, revokes the payslip links that were emailed for it, and inserts a fresh draft run for the same period with is_correction = true and corrects_run_id pointing back. The roster and line items are copied onto the correction run so the operator edits a populated draft. Idempotent. Dry-runnable.

Use when: A booked (and usually paid) month turns out wrong: a missing line, a wrong salary, a benefit that was not on the payslip. Call this first, then edit the correction run's lines and walk it through calculate, approve, mark-paid, book and generate-agi.

Don't use for: Runs that are not booked yet (draft, review, approved, paid): delete or edit them instead, nothing is posted. Fixing a single verifikation outside the salary lifecycle (POST /journal-entries/{id}/correct). Re-issuing payslips without changing amounts.

Pitfalls

  • Only booked runs can be corrected: any other status returns 409 SALARY_RUN_CORRECT_NOT_BOOKED with details.current_status.
  • The original's verifikat are reversed with storno (new reversing entries in the same series); nothing is edited or deleted. All reversed entry IDs are returned in reversed_entry_ids.
  • The correction run is a fresh draft for the same period: it must be attached (roster is copied for you), calculated, approved, paid, booked and its AGI regenerated. Nothing is posted by this verb.
  • A second call on the same run returns 409 SALARY_RUN_ALREADY_CORRECTED with details.correction_run_id: continue in that run instead.
  • Payslip links of the original are revoked immediately (employees see "ersatt"); fresh links are issued when the correction run's payslips are sent.
  • The arbetsgivardeklaration (AGI) for the period must be re-filed after the correction run books; Skatteverket receives the corrected figures, not a delta.
  • The storno entries land in the original payment_date's period: a locked period returns PERIOD_LOCKED and nothing is written. If the failure happens after the first storno, valid_alternatives.reversed_entry_ids names the entries already reversed and valid_alternatives.remaining_entry_ids the ones still posted; the run stays booked; call this verb again once the cause is fixed: the retry skips the entries already reversed and continues with the remaining ones.
  • Idempotency-Key is mandatory.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
original_run_idstring
original_status"corrected"
correction_runobject
reversed_entry_idsstring[]

Example response

{
  "data": {
    "original_run_id": "run_a8f1…",
    "original_status": "corrected",
    "correction_run": {
      "id": "run_c0rr…",
      "period_year": 2026,
      "period_month": 5,
      "payment_date": "2026-05-25",
      "status": "draft",
      "is_correction": true,
      "corrects_run_id": "run_a8f1…",
      "deviation_period_start": "2026-04-01",
      "deviation_period_end": "2026-04-30"
    },
    "reversed_entry_ids": [
      "je_salary…",
      "je_avg…",
      "je_vac…"
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi

salary-runs.generate-agi · scope payroll:write

Generate the Skatteverket AGI XML for a salary run.

Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits agi.generated, and auto-completes the arbetsgivardeklaration deadline. Returns the XML as a string field in the v1 envelope: agents extract data.xml and forward to Skatteverket directly (Mina Sidor upload or via a connected extension).

Use when: You've reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover).

Don't use for: Submitting the AGI to Skatteverket: this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) skatteverket extension.

Pitfalls

  • Run status must be one of review, approved, paid, booked, corrected: draft returns 400 AGI_GENERATE_NOT_BOOKABLE.
  • Generating AGI from a review-status run risks submitting figures that will change at :approve. The dashboard allows this for flexibility; agents should prefer approved+ unless an early-warning workflow specifically wants the preview.
  • Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee: different number = new record per Skatteverket spec.
  • AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying.
  • The XML content is räkenskapsinformation: BFL 7 kap retention applies. The agi_declarations row is never auto-deleted.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: no

Response fields

NameType
agi_declaration_idstring
period_yearnumber
period_monthnumber
employee_countnumber
is_correctionboolean
totalsobject
xmlstring
xml_filenamestring

Example response

{
  "data": {
    "agi_declaration_id": "agi_a8f1…",
    "period_year": 2026,
    "period_month": 5,
    "employee_count": 3,
    "is_correction": false,
    "totals": {
      "totalTax": 28500,
      "totalAvgifterBasis": 105000,
      "totalAvgifterAmount": 32991,
      "avgifterByCategory": {
        "standard": {
          "basis": 105000,
          "amount": 32991
        }
      }
    },
    "xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Skatteverket omrade=\"Arbetsgivardeklaration\">…</Skatteverket>",
    "xml_filename": "AGI_5566778899_202605.xml"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid

salary-runs.mark-paid · scope payroll:write

Mark an approved salary run as paid.

Advances a salary run from approved to paid and stamps paid_at. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use :book after this for that).

Use when: You've confirmed the salary payment hit employee bank accounts and want to advance the run's lifecycle so :book can post the verifikation.

Don't use for: Initiating the actual bank transfer (generate the bank file with POST /salary-runs/{id}/payment-file and upload it through the bank channel; this verb only records that it happened). Posting journal entries (use :book). Reverting a paid run (no :unpaid exists: call :correct once booked if you need to undo).

Pitfalls

  • Run must be in approved: non-approved runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED.
  • paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
idstring
status"paid"
paid_atstring

Example response

{
  "data": {
    "id": "run_a8f1…",
    "status": "paid",
    "paid_at": "2026-05-25T08:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/payment-file

salary-runs.payment-file · scope payroll:write

Generate the bank payment file (pain.001 or Bankgirot LB) for a salary run.

Builds the salary batch payment file for an approved (or paid / booked) run and returns it inline as a string: ISO 20022 pain.001.001.03 XML (pain001, default) or the legacy Bankgirot LB text file (bg_lb). One credit transfer per employee with a positive net payout, dated on the run's payment_date, category purpose SALA. Every generated file is archived as an immutable salary_payment_files row (BFL 7 kap. 1 §, seven-year retention) before it is returned; payment_file_id and sha256 identify that copy and GET /salary-runs/{id}/payment-files lists them. Stamps salary_runs.payment_file_format and payment_file_generated_at. Same preconditions and output as the dashboard's payment-file download.

Use when: The salary run is approved and you (or an external payroll operator) need the file to upload in the bank's corporate file channel to pay the salaries.

Don't use for: Marking the run paid (use :mark-paid after the bank has executed the batch), posting the verifikationer (use :book), paying supplier invoices (use the supplier-invoice payment batch), or sending anything to the bank: this call only produces the file.

Pitfalls

  • Run status must be one of approved, paid, booked: a draft or review run returns 409 SALARY_RUN_PAYMENT_FILE_NOT_READY. Approve the run first (:approve).
  • pain001 needs the company IBAN and a BIC (saved, or derived from the company clearing number / bank name) in company settings, plus clearing number and account number on every employee with a net payout. bg_lb needs a valid company bankgiro number. Missing company details return 422 SALARY_RUN_PAYMENT_FILE_MISSING_BANK_DETAILS (details.problem names the field); missing employee accounts return 422 SALARY_RUN_PAYMENT_FILE_EMPLOYEE_BANK_MISSING with details.employees.
  • An employee account the chosen format cannot carry returns 422 SALARY_RUN_PAYMENT_FILE_EMPLOYEE_BANK_INVALID with details.employees (employee_id, name, problem) for every affected employee at once; the response never echoes an account number. problem is clearing_format or account_format (correct the employee's bank details: clearing 4 digits or 5 starting with 8, account 5-10 digits without the clearing number) or bg_lb_account_too_long (a 5-digit clearing with a 10-digit account does not fit the fixed-width Bankgirot LB account field: request format pain001 instead). A dry run reports the same error, so preview before payday.
  • The file comes back inline as content (a string). Write it to disk under filename (pain001 as UTF-8, bg_lb as ISO 8859-1 with CRLF line endings, exactly as returned) and upload it in the bank's file channel. Nothing is transmitted to the bank by this call.
  • Generating the file does NOT mark the run paid and moves no money. Call :mark-paid once the bank has executed the batch, then :book to post the verifikationer.
  • Bankgirot LB is being retired by the banks during 2026: prefer pain001. format defaults to company_settings.preferred_payment_format, which is pain001 unless the company changed it.
  • Employees with a zero net payout (nollkörning, or net consumed by a nettolöneavdrag) are left out of the file and need no bank account; employee_count and total_amount cover only the paid lines. Regenerating is harmless: each call rebuilds the file, archives it as a new salary_payment_files row and re-stamps payment_file_generated_at.
  • Every generated file is archived and listable: the response carries payment_file_id (the archived row) and sha256 (over the bytes as encoded for the bank: UTF-8 for pain001, ISO 8859-1 for bg_lb). Compare it with the checksum of what you uploaded, and use GET /salary-runs/{id}/payment-files to retrieve exactly what was generated earlier instead of regenerating: a regeneration after a bank-detail change (new employee account, changed company IBAN) is a different file, and the archive is the record of what the bank actually received. An archive failure returns an error and no file.
  • The file always uses the run's payment_date as the requested execution date; the body accepts no execution date (unknown fields return 400). Change the run's payment_date (PATCH while draft) if the transfer day must move.
  • Dry run (?dry_run=true) validates every precondition and returns format, filename, payment_date, employee_count, total_amount and warnings without the content, without archiving and without stamping the run.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
format"pain001" | "bg_lb"no

Response fields

NameType
salary_run_idstring
payment_file_idstring
format"pain001" | "bg_lb"
filenamestring
content_type"application/xml" | "text/plain"
contentstring
sha256string
payment_datestring
employee_countnumber
total_amountnumber
currency"SEK"
warningsstring[]
generated_atstring

Example request

{
  "format": "pain001"
}

Example response

{
  "data": {
    "salary_run_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
    "payment_file_id": "f0f0f0f0-f0f0-4f0f-8f0f-f0f0f0f0f0f0",
    "format": "pain001",
    "filename": "pain001_lon_2026-05.xml",
    "content_type": "application/xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03\"><CstmrCdtTrfInitn>…</CstmrCdtTrfInitn></Document>",
    "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "payment_date": "2026-05-25",
    "employee_count": 3,
    "total_amount": 76500,
    "currency": "SEK",
    "warnings": [],
    "generated_at": "2026-05-20T08:00:00.000Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/revert

salary-runs.revert · scope payroll:write

Send a salary run in review back to draft so it can be edited.

Moves the run from review to draft. Nothing is deleted or booked: the calculated figures stay on the run until it is recalculated, and payslip lines, employees and salaries become editable again. Run POST /salary-runs/{id}/calculate afterwards to get back to review. Idempotent. Dry-runnable.

Use when: A calculated run needs a change before approval: a missing line, an employee added or removed, a corrected salary or absence in the deviation period.

Don't use for: An approved run (POST /salary-runs/{id}/unapprove first) or a paid or booked run (POST /salary-runs/{id}/correct).

Pitfalls

  • Only a run in review can be reverted: anything else returns 400 SALARY_RUN_REVERT_NOT_REVIEW with details.current_status.
  • A run that moves on between the check and the write returns 409 SALARY_RUN_STATUS_CHANGED: read it again.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameType
salary_run_idstring
status"draft"

Example response

{
  "data": {
    "salary_run_id": "run_a8f1…",
    "status": "draft"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/send-payslips

salary-runs.send-payslips · scope payroll:write

Email every employee on an approved salary run a secure link to their payslip.

Sends each employee on the run an email with a secure link to their lönebesked (never a PDF attachment: salary data and personnummer must not sit in inboxes). Each send rotates the employee's link, so a link emailed earlier for the run stops working. Every attempt, sent, failed or skipped for a missing email address, is written to the delivery log (salary_payslip_deliveries, BFL 7 kap.). One employee failing does not stop the others. Requires the run to be approved, paid or booked. Dry-runnable: the dry run lists the recipients and who lacks an email address, and sends nothing.

Use when: The run is approved (or paid/booked) and the employees should get their payslips, or a payslip should be re-sent after an employee's email address was corrected.

Don't use for: Fetching the payslip document yourself (GET /salary-runs/{id}/payslips/{employeeId}/pdf) or reading payslip amounts (GET /salary-runs/{id}/employees/{employeeId}).

Pitfalls

  • A draft or review run returns 400 SALARY_PAYSLIPS_SEND_INVALID_STATUS: approve it first.
  • Re-sending emails everyone on the run again and invalidates the links sent before.
  • Employees without an email address are skipped and counted in skipped, not an error: fix the address with PATCH /employees/{id} and send again.
  • Refused with 403 from the sandbox company (SALARY_PAYSLIPS_SEND_SANDBOX) and without the email capability (SALARY_PAYSLIPS_SEND_CAPABILITY_BLOCKED).
  • Not idempotent towards the recipients: a replay with a new Idempotency-Key emails everyone again.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameTypeDescription
salary_run_idstring
sentnumber
skippednumberEmployees without an email address (logged as skipped).
failednumber
totalnumber
deliveriesobject[]

Example response

{
  "data": {
    "salary_run_id": "run_a8f1…",
    "sent": 2,
    "skipped": 1,
    "failed": 0,
    "total": 3,
    "deliveries": [
      {
        "employee_id": "emp_1…",
        "employee_name": "Anna Andersson",
        "status": "sent",
        "error": null
      },
      {
        "employee_id": "emp_2…",
        "employee_name": "Björn Berg",
        "status": "skipped",
        "error": "Anställd saknar e-postadress"
      }
    ]
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary-runs/:id/unapprove

salary-runs.unapprove · scope payroll:write

Recall the approval of a salary run (approved back to review).

Moves an approved run back to review and clears the approver, the AGI generation stamp and the payment-file tracking. A generated or exported AGI declaration that never reached Skatteverket is deleted, because its amounts may change. Refused once the AGI is being signed or has been filed: the period is then changed with a corrected AGI. A paid or booked run is never unapproved; it is corrected. Payslips already emailed are not recalled. Idempotent. Dry-runnable: the dry run names the AGI declaration it would delete, whether a payment file was generated and how many payslips were sent.

Use when: An approved run turns out wrong before it was paid and before the AGI was filed, and must be recalculated.

Don't use for: A paid or booked run (POST /salary-runs/{id}/correct) or a period whose AGI was filed (file a corrected AGI).

Pitfalls

  • Only an approved run: anything else returns 400 SALARY_RUN_UNAPPROVE_NOT_APPROVED.
  • An AGI in pending_signature, submitted or accepted (or agi_submitted_at set) returns 409 SALARY_RUN_UNAPPROVE_AGI_FILED.
  • A payment file generated for the run may already be with the bank: the API cannot know. Check before recalling, or salaries may be paid on the old amounts.
  • To edit the run afterwards, also revert it to draft (POST /salary-runs/{id}/revert).

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Response fields

NameTypeDescription
salary_run_idstring
status"review"
deleted_agi_declaration_idstring | nullThe generated but unfiled AGI declaration removed as stale, or null.

Example response

{
  "data": {
    "salary_run_id": "run_a8f1…",
    "status": "review",
    "deleted_agi_declaration_id": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/salary/vacation-year-close

salary.vacation-year-close · scope payroll:write

Close a vacation year (semesterberedning + arsavslut).

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.

Don't 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 §).

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
vacation_year_startstringno
book_adjustmentbooleanno

Response fields

NameType
vacation_year_closure_idstring
adjustment_entry_idstring | null
reportunknown (optional)

Example request

{
  "book_adjustment": true
}

Example response

{
  "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"
  }
}

PATCH /api/v1/companies/:companyId/salary-runs/:id

salary-runs.update · scope payroll:write

Update a draft salary run.

Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allowed when status === "draft": once :calculate has advanced the run to review, these fields are frozen because they feed into the verifikation that :book will eventually post.

Use when: You created a draft, then noticed payment_date should be different (e.g. moved from the 25th to the 23rd) before running :calculate.

Don't use for: Changing period_year / period_month (immutable: DELETE the draft and create a new one). Modifying employees in the run (not in v1 PR-1 scope).

Pitfalls

  • Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft".
  • period_year + period_month are immutable post-create.
  • payment_date may fall outside the run's period month (lön i efterskott): the AGI redovisningsperiod follows the payment month (kontantprincipen), so a run for August paid on 25 September is declared for September.
  • Supplying payment_date clears every roster row's calculation_breakdown, so an already-calculated run must be recalculated before :approve/:book.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
payment_datestringno
voucher_seriesstringno
notesstring | nullno

Response fields

NameType
idstring
period_yearnumber
period_monthnumber
payment_datestring
deviation_period_startstring | null
deviation_period_endstring | null
status"draft" | "review" | "approved" | "paid" | "booked" | "corrected"
voucher_seriesstring
total_grossnumber
total_taxnumber
total_netnumber
total_avgifternumber
total_vacation_accrualnumber
total_employer_costnumber
salary_entry_idstring | null
avgifter_entry_idstring | null
vacation_entry_idstring | null
agi_generated_atstring | null
agi_submitted_atstring | null
calculation_paramsunknown (optional)
approved_bystring | null
approved_atstring | null
paid_atstring | null
booked_atstring | null
booked_bystring | null
notesstring | null
created_atstring
updated_atstring

Example request

{
  "payment_date": "2026-05-23"
}

Example response

{
  "data": {
    "id": "run_…",
    "payment_date": "2026-05-23",
    "status": "draft"
  }
}

PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId

salary-runs.lines.update · scope payroll:write

Update a payslip line in a draft salary run.

Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number, one_off_tax_percent, vacation_category, vacation_saved_year) while the run is a draft. Amounts are rounded to whole öre. one_off_tax_percent: null removes the engångsskatt and returns the line to table taxation. vacation_category: null returns a vacation line to this year's paid days.

Use when: You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.

Don't use for: Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.

Pitfalls

  • Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
  • A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.
  • Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.
  • The row is validated as it reads after the patch: flipping is_net_deduction or is_gross_deduction on, setting is_taxable false, or making the amount non-positive on a line that carries one_off_tax_percent is refused with 400 VALIDATION_ERROR; clear the percentage (null) in the same call.
  • vacation_category (paid, extra_paid, saved, unpaid, advance) is only valid while item_type is vacation, and vacation_saved_year only with category saved; a patch that breaks either is refused with 400 VALIDATION_ERROR.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
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"no
descriptionstringno
quantitynumberno
unit_pricenumberno
amountnumberno
is_taxablebooleanno
is_avgift_basisbooleanno
is_vacation_basisbooleanno
is_gross_deductionbooleanno
is_net_deductionbooleanno
account_numberstringno
sort_ordernumberno
one_off_tax_percentnumber | nullno
vacation_category"paid" | "extra_paid" | "saved" | "unpaid" | "advance" | nullno
vacation_saved_yearstring | nullno

Response fields

NameType
salary_line_item_idstring
salary_run_employee_idstring
item_typestring
descriptionstring
quantitynumber | null
unit_pricenumber | null
amountnumber
is_taxableboolean
is_avgift_basisboolean
is_vacation_basisboolean
is_gross_deductionboolean
is_net_deductionboolean
account_numberstring | null
sort_ordernumber
one_off_tax_percentnumber | null (optional)
vacation_categorystring | null (optional)
vacation_saved_yearstring | null (optional)

Example request

{
  "amount": 5500
}

Example response

{
  "data": {
    "salary_line_item_id": "sli_31c9…",
    "amount": 5500
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

PATCH /api/v1/companies/:companyId/salary/settings

salary.settings.update · scope payroll:write

Partially update the company payroll settings.

Patches the payroll settings: salary_pay_day (1-28), salary_deviation_period (same_month | previous_month), preferred_payment_format (pain001 | bg_lb), salary_default_bank (swedbank | seb | handelsbanken | nordea | other | null), salary_net_rounding (boolean), salary_calculation_policy (an object with any of partial_month: workdays | annual_calendar_days, sick_rate: daily_divisor | annual_hourly, long_leave: workdays | calendar_after_five_workdays, leave_context: all_registered | through_deviation_end, net_rounding: up | nearest, one_off_tax_rounding: truncate | nearest; merged key by key into the stored policy) and salary_voucher_series (one letter A-Z). All fields optional; at least one must be supplied; unknown fields are rejected. Upserts: a company without a settings row gets one created with the supplied values and DB defaults for the rest. Returns the full resource after the write. Idempotent (mandatory Idempotency-Key). Dry-runnable: ?dry_run=true returns the merged resource without writing.

Use when: You are onboarding a customer for payroll over the API (set the pay day, avvikelseperiod, calculation conventions, payment file format, bank and voucher series before the first run), a customer changes bank or pay day, or a customer migrated from Fortnox needs the same partial-month, sick-pay and long-leave conventions as their old payslips.

Don't use for: Invoice payment and contact details (PATCH /api/v1/companies/{companyId}/settings). Changing the payment date or deviation window of an existing run (PATCH /salary-runs/{id}, or explicit deviation_period_start/end on POST). Changing the conventions of a run that is already calculated (recalculate the draft, or :correct a booked run). Tax and legal profile changes (not on the public API).

Pitfalls

  • Idempotency-Key is mandatory; calls without it return 400.
  • At least one field must be supplied; an empty body returns 400. Unknown fields return 400 (strict body), also inside salary_calculation_policy.
  • salary_deviation_period is snapshotted onto each salary run at creation: changing it never moves a run that already exists. Set it before the first run of a new month. Switching later makes the next run's deviation window overlap the previous run's window, and that run is refused with 409 SALARY_RUN_DEVIATION_PERIOD_OVERLAP (pass explicit deviation_period_start/end on that one run to bridge the switch).
  • salary_pay_day only drives the default payment_date of NEW runs (the day of the pay month, 1-28 so it exists in every month). Existing runs keep their payment_date; override per run on POST /salary-runs.
  • salary_voucher_series is an alias for company_settings.default_voucher_series_per_source_type.salary_payment. Writes MERGE that one key into the per-source-type map; the other source types keep their letters. The default company layout books salaries on K.
  • preferred_payment_format: pain001 (ISO 20022) is the default; bg_lb (Bankgirot Leverantörsbetalningar / Lön) is being retired by the banks during 2026, so only pick it for a customer whose bank still accepts LB files.
  • salary_calculation_policy holds the company's calculation conventions (beräkningsprinciper). Every key defaults to the historical Accounted behaviour; a customer migrated from Fortnox usually wants partial_month=annual_calendar_days (månadslön × 12 / 365 per calendar day employed), sick_rate=annual_hourly (timlön = månadslön × 12 / (52 × veckoarbetstid) for sjuklön), long_leave=calendar_after_five_workdays (leave longer than five working days deducted per calendar day at månadslön × 12 / 365, a whole month = the monthly salary) and, with salary_net_rounding, net_rounding=nearest. Compare one historical payslip before switching.
  • A PATCH of salary_calculation_policy is merged key by key into the stored policy (omitted keys keep their value); the response and the stored value always carry all six keys. It is not snapshotted onto existing runs at creation: the conventions are read at :calculate and frozen into the run's calculation_params, so a draft recalculated after a change follows the new conventions and a calculated run does not.
  • long_leave=calendar_after_five_workdays is a five-day-week rule: :calculate refuses (400 VALIDATION_ERROR) a monthly employee whose workdays_per_week is not 5 while it is on. leave_context only matters under that convention.
  • one_off_tax_rounding governs engångsskatt on payslip lines that carry one_off_tax_percent (POST /salary-runs/{id}/employees/{employeeId}/lines); truncate (öretal bortfaller) is the statutory rule, nearest exists to reproduce another system's history.
  • salary_default_bank: null clears the bank; omitting the field leaves it unchanged. The bank only pre-selects upload instructions, it does not change the payment file format.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Request body

NameTypeRequired
salary_pay_daynumberno
salary_deviation_period"same_month" | "previous_month"no
preferred_payment_format"bg_lb" | "pain001"no
salary_default_bank"swedbank" | "seb" | "handelsbanken" | "nordea" | "other" | nullno
salary_net_roundingbooleanno
salary_calculation_policyobjectno
salary_voucher_seriesstringno

Response fields

NameType
company_idstring
salary_pay_daynumber
salary_deviation_period"same_month" | "previous_month"
preferred_payment_format"pain001" | "bg_lb"
salary_default_bank"swedbank" | "seb" | "handelsbanken" | "nordea" | "other" | null
salary_net_roundingboolean
salary_calculation_policyobject
salary_voucher_seriesstring

Example request

{
  "salary_pay_day": 25,
  "salary_deviation_period": "previous_month",
  "salary_default_bank": "swedbank",
  "salary_net_rounding": true,
  "salary_calculation_policy": {
    "partial_month": "annual_calendar_days",
    "sick_rate": "annual_hourly",
    "long_leave": "calendar_after_five_workdays",
    "net_rounding": "nearest"
  },
  "salary_voucher_series": "K"
}

Example response

{
  "data": {
    "company_id": "aaaa1111-2222-4333-8444-555566667777",
    "salary_pay_day": 25,
    "salary_deviation_period": "previous_month",
    "preferred_payment_format": "pain001",
    "salary_default_bank": "swedbank",
    "salary_net_rounding": true,
    "salary_calculation_policy": {
      "partial_month": "annual_calendar_days",
      "sick_rate": "annual_hourly",
      "long_leave": "calendar_after_five_workdays",
      "leave_context": "all_registered",
      "net_rounding": "nearest",
      "one_off_tax_rounding": "truncate"
    },
    "salary_voucher_series": "K"
  },
  "meta": {
    "request_id": "req_...",
    "api_version": "2026-05-12"
  }
}

DELETE /api/v1/companies/:companyId/salary-runs/:id

salary-runs.delete · scope payroll:write

Delete a draft salary run.

Hard-deletes a salary run. ONLY allowed when status === "draft": once the run has calculated numbers or posted a verifikation, BFL 5 kap immutability applies and storno is the only correction path. CASCADE deletes salary_run_employees and salary_line_items.

Use when: You created a run by mistake or want to recreate it with different period_month. Only draft runs can be deleted.

Don't use for: Reverting a booked run (POST /salary-runs/{id}/correct). Hiding a run from listings (no soft-delete on this table: drafts are truly removed).

Pitfalls

  • Returns 400 SALARY_RUN_DELETE_NOT_DRAFT for any status other than draft.
  • Hard delete: the salary_run_employees + salary_line_items rows cascade away.
  • Idempotent in the absent-row sense: DELETE on a non-existent id returns 404 SALARY_RUN_NOT_FOUND rather than re-emitting a deletion event.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (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/salary-runs/:id/lines/:lineId

salary-runs.lines.delete · scope payroll:write

Delete a payslip line from a draft salary run.

Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.

Use when: A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.

Don't use for: Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).

Pitfalls

  • Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
  • Deleting an engine-derived line is futile: :calculate regenerates it from source data.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Query parameters

NameTypeRequiredDescription
dry_runstringnotrue (any case) previews the write without committing it, like the X-Dry-Run: true header. Any other value commits.

Example response

{
  "data": null
}