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.GET/api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf: Download one employee's payslip as PDF.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.PATCH/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId: Update a payslip line in a draft salary run.DELETE/api/v1/companies/:companyId/salary-runs/:id: Delete a draft salary run.DELETE/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId: Delete a payslip line from 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: 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
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/payslips/:employeeId/pdf {#get-salary-runs-payslip-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: the send flow is internal-only today.
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."
}
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"
}
}
PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId {#patch-salary-runs-lines-update}
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) while the run is a draft. Amounts are rounded to whole öre.
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.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Example request
{
"amount": 5500
}
Example response
{
"data": {
"salary_line_item_id": "sli_31c9…",
"amount": 5500
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
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
}
DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId {#delete-salary-runs-lines-delete}
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
Example response
{
"data": null
}