Salary runs
Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.
Endpoints
GET/api/v1/companies/:companyId/salary-runs— List salary runs.GET/api/v1/companies/:companyId/salary-runs/:id— Get a salary run.POST/api/v1/companies/:companyId/salary-runs— Create a salary run.POST/api/v1/companies/:companyId/salary-runs/:id/approve— Approve a reviewed salary run.POST/api/v1/companies/:companyId/salary-runs/:id/book— Post the verifikationer for a paid salary run.POST/api/v1/companies/:companyId/salary-runs/:id/calculate— Calculate a draft salary run and advance it to review.POST/api/v1/companies/:companyId/salary-runs/:id/generate-agi— Generate the Skatteverket AGI XML for a salary run.POST/api/v1/companies/:companyId/salary-runs/:id/mark-paid— Mark an approved salary run as paid.PATCH/api/v1/companies/:companyId/salary-runs/:id— Update a draft salary run.DELETE/api/v1/companies/:companyId/salary-runs/:id— Delete a draft salary run.
GET /api/v1/companies/:companyId/salary-runs {#get-salary-runs-list}
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.
correctedstatus 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
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 {#get-salary-runs-get}
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 (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report — use GET /reports/salary-journal in Phase 5 PR-3.
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
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"
}
}
POST /api/v1/companies/:companyId/salary-runs {#post-salary-runs-create}
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 (that is a separate surface — see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up).
Pitfalls
- Idempotency-Key is mandatory.
- Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.
- 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
Example request
{
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"voucher_series": "L"
}
Example response
{
"data": {
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "draft",
"voucher_series": "L"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/salary-runs/:id/approve {#post-salary-runs-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 (the lifecycle has no :unapprove — call :correct once the run is booked if you need to undo).
Pitfalls
- Run must be in
review— non-reviewruns return 400 SALARY_RUN_APPROVE_NOT_REVIEW. - Every employee on the run needs a
clearing_number+bank_account_number. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list. - Every employee on the run needs
calculation_breakdownpopulated. If you skipped:calculatesomehow, 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
:bookwhere the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period;:bookwill later refuse.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
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 {#post-salary-runs-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-paidruns 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_idand a hint of what unlock action is needed. - BFL 5 kap immutability: once
:booksucceeds 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
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 {#post-salary-runs-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 — call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard).
Pitfalls
- Run must be in
draftstatus — 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
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/generate-agi {#post-salary-runs-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 —
draftreturns 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 preferapproved+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
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,
"totalSjuklonekostnad": 0,
"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 {#post-salary-runs-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 (the v1 API does not yet expose payment-file generation; use the dashboard's payment-file endpoints). 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-approvedruns 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
Example response
{
"data": {
"id": "run_a8f1…",
"status": "paid",
"paid_at": "2026-05-25T08:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/:companyId/salary-runs/:id {#patch-salary-runs-update}
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.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes
Example request
{
"payment_date": "2026-05-23"
}
Example response
{
"data": {
"id": "run_…",
"payment_date": "2026-05-23",
"status": "draft"
}
}
DELETE /api/v1/companies/:companyId/salary-runs/:id {#delete-salary-runs-delete}
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 (use the internal /correct flow; v1 promotion deferred). 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
Example response
{
"data": null
}