SlowBooks Pro v2.31.4 · saved from https://www.slowbookspro.com/docs/payroll-hr/, where the guide for the latest version always is.

Payroll and HR

SlowBooks runs payroll in-house: employees, pay runs with federal and state withholding, direct deposit files, W-2/W-3/940/941 and the state SUI report, benefits and garnishments, time entries and time off, new-hire onboarding, and a self-service portal for employees. This document is the reference for where each piece lives and how the routes fit together; the state tables are in State withholding.

Who can use it. Payroll and HR need an administrator signed in. Bookkeeper, accountant and read-only sign-ins, and every API token (a token is never an administrator since 2.29.0; older administrator tokens stopped working in 2.31.2), are refused on /api/payroll, /api/benefits, /api/deductions, /api/onboarding, /api/tax-forms, /api/document-audits and the employee portal-token, bank-account and YTD routes. Payroll forms stay on the calendar year whatever fiscal year the company reports on.


Federal withholding

Federal income tax follows IRS Publication 15-T's percentage method for automated payroll systems: the 2026 STANDARD and Step 2 checkbox schedules for single, married filing jointly and head of household, the Step 3 credits and Step 4 amounts from the employee's W-4, the 22% supplemental rate (37% over $1,000,000), Social Security 6.2% to the year's wage base, Medicare 1.45% plus the 0.9% additional over $200,000, FUTA 0.6% on the first $7,000 (app/services/payroll_service.py, each constant with the publication it came from). The schedules are the year's own as published in December; check the January pay run against the new Pub 15-T each year. Only the Social Security wage base is kept per year: a pay run dated in another year (back-dated 2025 payroll entered during a conversion, say) is figured with this version's schedules and tables and carries a warning saying so, wherever the run is read (a run an earlier version made shows it too: that version kept one year's tables the same way).

Washington PFML's employer share and Paid Leave Oregon's apply by headcount: the pay run counts the company's active employees and charges the employer share only from those sizes, Oregon by every employee (25 or more), Washington by the employees working in Washington (50 or more); the employee's share is withheld either way.

State withholding

All 50 states + DC: dedicated engines for WA / CA / NY / OR, a table-driven engine for the rest (app/services/state_tax/tables.py). Parameters, sources and the employee inputs (allowances, extra withholding, elected rate, local rate) are in State withholding. Reciprocity in state_tax/reciprocity.py.

Onboarding, time entries and time off

Models

FileClassPurpose
app/models/hr.pyOnboardingChecklist, OnboardingTask8-task new-hire checklist with sign-off
app/models/time_entries.pyTimeEntryPer-day hours with state, approval status
app/models/pto.pyPTOPolicy, PTORequest, PTOAccrualPolicy definitions, requests, YTD balances

Routes

Method + PathPurpose
GET /api/onboarding/{emp_id}Fetch checklist (the default, unsaved and without task ids, until it is seeded)
POST /api/onboarding/{emp_id}/seedSave the default tasks (adds any missing; never resets completed ones). The Onboarding page calls it when View Checklist is opened
PUT /api/onboarding/tasks/{task_id}Mark signed / complete
POST /api/onboarding/tasks/{task_id}/completeConvenience for the checkbox
GET /api/onboarding/{emp_id}/new-hire-reportJSON for state new-hire reporting
GET /api/onboarding/{emp_id}/new-hire-report/pdfDownloadable PDF
GET /api/time-entriesList, filterable by employee/date range
POST /api/time-entriesCreate a time entry
PUT /api/time-entries/{entry_id}Edit (not status, and blocked once paid or costed to a job)
DELETE /api/time-entries/{entry_id}Remove (blocked once paid or costed to a job)
POST /api/time-entries/{entry_id}/submitMove draft → submitted
POST /api/time-entries/{entry_id}/approveManager workflow; approver is the signed-in user (body approved_by ignored)
POST /api/time-entries/{entry_id}/rejectManager workflow
POST /api/time-entries/{entry_id}/post-to-job, POST /api/time-entries/post-to-jobPost an approved entry (or several) to its job as labor cost, one job cost entry each
POST /api/time-entries/classifyRun the overtime engine (FLSA plus state rules) over raw daily hours
GET /api/time-entries/summary?period_start=&period_end=Approved unpaid hours per employee for a pay period
GET /api/pto/policiesAll policies
GET /api/pto/policies/{policy_id}Single policy
POST /api/pto/policiesNew policy
PUT /api/pto/policies/{policy_id}Edit policy
GET /api/pto/requestsAll time-off requests, filterable
POST /api/pto/requestsSubmit a request
POST /api/pto/requests/{request_id}/decisionCanonical approve/reject endpoint
POST /api/pto/requests/{request_id}/approveAlias for decision(status=approved)
POST /api/pto/requests/{request_id}/rejectAlias for decision(status=denied)
POST /api/pto/requests/{request_id}/cancelCancel a pending or approved request; an approved one puts the hours it drew back into the bank and reverses its relief journal

Frontend


Benefits and garnishments

Models

FileClassPurpose
app/models/benefits.pyBenefitCode, BenefitRateThe rule + its effective-dated rates/limits (replaces DeductionType)
EmployeeGroup, EmployeeGroupBenefitTemplates: a set of codes applied to everyone in the group
EmployeeBenefitPer-employee assignment with overrides, caps, loan balance (replaces EmployeeDeduction)
BenefitYTD, PayStubBenefitYTD accumulators; posted-run snapshots
GarnishmentCourt-ordered wage garnishment with priority

Routes

Method + PathPurpose
GET/POST /api/benefits/codes, PUT/DELETE …/{id}Benefit code catalog (retire, never delete)
POST /api/benefits/codes/seed-standardSection 125, HSA, 401(k) w/ tiered match, Roth, union, loan, employer health, GTL
GET/POST /api/benefits/codes/{id}/ratesEffective-dated rates + limits (previous row closes itself)
GET/POST/PUT/DELETE /api/benefits/groups…Employee groups, their code sets and members
GET/POST/PUT/DELETE /api/benefits/enrollmentsPer-employee assignments (end-date keeps history)
GET /api/benefits/employee/{id}/resolvedWhat the next run applies, in sequence
GET /api/benefits/ytd, POST …/ytd/rebuildAccumulators, repair from snapshots
GET /api/benefits/remittance, POST …/remittance/billPer-vendor withheld + employer totals; vendor bill relieving the liability
POST /api/pto/accruals/{id}/revalueRestate a PTO bank's dollars at the current rate
GET /api/deductions/garnishmentsList, filterable by ?employee_id=...
POST /api/deductions/garnishmentsCreate a garnishment order
POST /api/deductions/garnishments/{order_id}/endEnd an order (the page's End order button); it is no longer withheld and its record stays. DELETE is refused (405)

Frontend


Tax forms

Routes

Two endpoint families per form. JSON endpoints are the machine-readable contract (useful for future e-file integration); PDF endpoints render through WeasyPrint with the employer's name + EIN in the header and a tamper-evident audit hash in the footer. The SPA's "Generate" buttons hit the PDF variants.

Method + PathReturns
POST /api/payroll/forms/w2/{emp_id}?year=YYYYW-2 boxes 1-6, boxes 15-17 per state (states) + employee/employer identifiers (JSON)
GET, POST /api/payroll/forms/w2/{emp_id}/pdf?year=YYYYW-2 PDF + document_audits row
POST /api/payroll/forms/w3/{year}W-3 aggregate across all active employees (JSON)
GET, POST /api/payroll/forms/w3/{year}/pdfW-3 PDF
POST /api/payroll/forms/940/{year}Form 940 FUTA — first $7,000 per employee at 0.6% (6.0% less the full state credit). When the company's state is a credit-reduction state for the year (Schedule A: California 1.2% and the U.S. Virgin Islands 4.5% for 2025), the form adds line 11 and line 12 for it (2.31.3); every employee's wages are treated as paid in the company's state. Schedule A for 2026 is published in November and added then (JSON)
GET, POST /api/payroll/forms/940/{year}/pdfForm 940 PDF
POST /api/payroll/forms/941/{year}/{quarter}Quarterly Form 941 figures: lines 1-3, 5a-5e, 6, 7 and 12 (JSON). Deposits and balance due (lines 11-13), Schedule B and Form 941-X are not produced
GET, POST /api/payroll/forms/941/{year}/{quarter}/pdfForm 941 PDF
GET /api/document-auditsList audit rows (newest first), filterable by doc_type + doc_key
GET /api/document-audits/{id}One audit row — for verifying a PDF by its footer ID
GET /api/document-audits/verify/{content_hash}Find rows by full SHA-256 hash
GET /api/document-audits/{id}/checkRe-figure the form from today's books; matches, both hashes, voided_runs and a sentence (message)

Frontend


Self-service portal

Models

Employee.portal_token (192-bit, from secrets.token_urlsafe(24)), plus expiration tracking:

portal_token = Column(String(64), nullable=True, unique=True)
portal_token_last_used = Column(DateTime(timezone=True), nullable=True)
portal_token_expires_at = Column(DateTime(timezone=True), nullable=True)

Routes — admin (session-authed)

Method + PathPurpose
GET /api/employees/{id}/portal-tokenThe token + expiry as they stand; never mints (null with a note when there is none)
POST /api/employees/{id}/portal-tokenMake the token, or rotate it (resets expiry windows)

On the employee's Details, Make Link makes the first link and Rotate Token replaces it; Copy Link and Email to Employee… send it. Reading never creates one, and only an administrator sees the section.

Routes — employee (token-authed, rate-limited)

All return Referrer-Policy: no-referrer and Cache-Control: no-store.

Method + PathRate limitPurpose
GET /portal/{token}30/minDashboard
GET /portal/{token}/paystubs30/minList processed pay stubs
GET /portal/{token}/profile30/minW-4 + address form
POST /portal/{token}/profile10/minSave W-4 + address
GET /portal/{token}/bank30/minList direct-deposit accounts
POST /portal/{token}/bank10/minAdd bank account (Fernet-encrypted at rest)
GET /portal/{token}/pto30/minBalances + request form
POST /portal/{token}/pto10/minSubmit a PTO request
POST /portal/pto/{request_id}/withdraw10/minWithdraw the employee's own request while it is still pending

After the first visit the browser is redirected to /portal/… without the token, which lives in an HttpOnly cookie for 30 days; the token appears in a URL only on that first claim.

Token lifecycle

Where the portal link works. The link is absolute and uses the address the request came in on, so it is only as reachable as the server: a desktop install is 127.0.0.1 on that PC only; Server Edition on the LAN gives a LAN address; employees outside the building need a server they can reach: a cloud host behind HTTPS (docs/cloud-hosting.md) or a tunnel to the office host with a bypass for /portal/ (docs/server-edition.md, "Reaching it from outside the office"). In the desktop app the link opens in the system browser on purpose — the portal is a separate cookie-based site and must not be rendered in the app's document window.


Admin UI pages

RoutePageJS module
#/hr/onboardingEmployee list with completion %onboarding.js
#/hr/time-entriesTime entries with approve/rejecttime_entries.js
#/hr/ptoTime Off: policies + pending requestspto.js
#/hr/benefitsBenefit codes, groups, enrollments, remittancebenefits.js
#/hr/deductionsGarnishmentsdeductions.js
#/hr/tax-formsW-2/W-3/940/941 generation + Form history (who, when, hash, Check)tax_forms.js
#/employees → DetailsOverview, Portal Access, E-Verify, YTD Totals, Opening YTD, Bank Accounts, Documentsemployees.js

Direct deposit

SlowBooks makes the ACH file (NACHA format); the bank moves the money. The business account needs ACH origination from the bank, which then gives the numbers Settings → Direct Deposit (ACH) asks for: the bank's routing number and name, the company account the net pay is paid from (ach_company_account, encrypted at rest like the SMTP password) and the two 10-character identifiers (ach_immediate_origin, ach_company_id), which are "1" + the EIN unless the bank says otherwise and are filled from the company tax id when blank. Employees' own accounts are entered on the employee (/api/employees/{id}/bank-accounts) or by them in the portal.

Method + PathPurpose
GET /api/payroll/{run_id}/nachaThe file for a processed run, from the saved settings; the pay run view's Direct deposit file button. attachment, named Direct-Deposit_<pay date>.ach
POST /api/payroll/{run_id}/nachaThe same with any originating field in the body outranking the saved one (effective_date sets the batch's effective entry date); an empty body is the saved settings

Each file made is an EXPORT row in the audit trail on pay_runs: who made it, for which run, how many it credits, the total and the effective date. The account numbers stay in the file, never in the trail. A refused download leaves no row.

A refusal says why: 400 when the run is not processed; 409 naming the settings still blank, or that no one on the run has an active account (an account in its prenote waiting period is not paid). Each stub of a run carries direct_deposit: true when the file pays it, false means pay by check; the view shows the split and a Paid by column. Admin only, like the rest of payroll.


Voiding a pay run

POST /api/payroll/{run_id}/void (the run's Void button) reverses the journal entry the run posted, line for line, marks the run void and puts back what it took: time entries are released, benefit and loan balances step back, YTD figures drop the run. A run whose entry was already reversed by hand, or whose bank line sits in a finished reconciliation, is refused with the reason. Three things make the void ask first (409 confirm_void, sent again with confirm=1 to go ahead): a direct deposit file was made from the run, a liability the run credited was paid out since its pay date (a 941 deposit, net pay paid out of 2300), or a W-2, W-3, 940 or 941 was generated since that counted it. The response's warnings repeat them.


Posting accounts (#620)

A pay run posts one journal entry: a debit for gross wages, one for the employer's payroll taxes, a credit to each payable for what was withheld or accrued, and a credit for the net pay. Each piece has a setting that names its account (Settings → Payroll Accounts, administrator only, saved with the rest of the company settings by PUT /api/settings). Left blank, the piece posts to the seeded account it always has, so a company that chooses nothing posts exactly as before. The table is app/services/payroll_accounts.py.

PieceSettingTakesDefault
Net paypayroll_net_pay_account_ida bank account, or a liability1000 Checking
Gross wagespayroll_wages_account_idan expense or cost of goods sold account6110 Wages & Salaries (6000 on a chart without it)
Employer payroll taxespayroll_employer_tax_account_idan expense or cost of goods sold account6120 Payroll Tax Expense (6000)
Federal income tax withheldpayroll_federal_withholding_account_ida liability2310 (2300)
State income tax withheldpayroll_state_withholding_account_ida liability2320 (2300)
Social Security payable (both shares)payroll_social_security_account_ida liability2330 (2300)
Medicare payable (both shares)payroll_medicare_account_ida liability2340 (2300)
FUTA payablepayroll_futa_account_ida liability2350 (2300)
SUTA payablepayroll_suta_account_ida liability2360 (2300)
Other payroll deductions payablepayroll_other_deductions_account_ida liability2370 (2300)

Reimbursements (6140, else 6950) and benefit codes (each code's own accounts, 6150 for the rest) are not settings. The save refuses an account of the wrong type, an inactive one, a credit card, or a control liability (2000 Accounts Payable, 2200 Sales Tax Payable, whose balances are kept by their own documents), in a sentence saying what the piece takes; nothing is saved when one choice is refused. GET /api/settings/payroll-accounts lists the pieces with their chosen and default accounts and, per kind, the accounts each may be pointed at; the Settings page draws its pickers from it. A chosen account that has since been deleted, made inactive or retyped refuses the process (400) naming it and where to fix it, rather than posting the piece somewhere else.

Net pay paid later. With Net pay set to 2300 Payroll Liabilities (or a liability made for it, "Wages Payable"), processing the run credits that account instead of the bank: the wages are a liability on the Balance Sheet until they are paid, and the bank is untouched. When the pay goes out, record it against that account: a check or register entry from the bank with 2300 as its category, a bill with a line on 2300 and its bill payment, or a journal entry debiting 2300 and crediting the bank. The direct deposit file is made the same way as before, from the processed run; it does not post. The Process button's overdraft question is for a bank account; a liability is not asked about.

Void. The void reverses the entry the run posted, line for line, so it puts back whatever accounts the run used at the time (2300 for the net pay, say), whatever the settings say by then. A payment out of a liability the run credited, dated since its pay date, is a caution the void asks about first, as a 941 deposit is: net pay paid out of 2300 included.


Opening YTD balances (#433)

A company that switches to SlowBooks mid-year enters what the old payroll paid that year, once per employee, quarter by quarter through as_of, the day before the first SlowBooks pay run (app/models/payroll_opening.py, app/services/payroll_opening.py). No pay run and no journal entry: the old system booked that payroll, and the opening balance sheet carries what it still owes.

TableHolds
payroll_opening_balancesemployee, year, as_of, note
payroll_opening_quartersper quarter (1 to the quarter of as_of): gross, federal withholding, SS / Medicare / FUTA wages and tax both sides, pre- and post-tax deductions, garnishments, net
payroll_opening_state_balancesper quarter and state: wages, withholding, SUTA wages and tax, other employee / employer state taxes
payroll_opening_benefitsper benefit code, for the year: employee and employer amounts (the annual caps count them)

Who reads it, each on purpose:

Locked once a pay run in the year is processed for the employee: a change or a delete then needs a reason, written to the audit trail as CORRECT with the before and after, and as_of stays before that run. A change or a delete sets stale_reason on the employee's draft runs for the year; processing one is refused (409) until acknowledge_stale=1 is sent, or it is discarded (DELETE /api/payroll/{id}) and created again.

Method + PathPurpose
GET /api/payroll/opening-ytd?year=Every employee's opening balance for the year
GET /api/employees/{id}/opening-ytd?year=One employee's (year totals, states summed, quarters as entered), with locked and first_pay_date
PUT /api/employees/{id}/opening-ytdCreate or replace: year, as_of, quarters[] (each with states[]), benefits[], reason once locked
DELETE /api/employees/{id}/opening-ytd?year=&reason=Remove (reason once locked)
GET /api/payroll/opening-ytd/templateThe CSV template
POST /api/payroll/opening-ytd/import?dry_run=1|0&reason=Import the template: the plan, then the same plan applied
POST /api/payroll/{id}/process?acknowledge_stale=1Process a draft flagged by an opening change, as figured
DELETE /api/payroll/{id}Discard a draft: delete it and its stubs; take its benefit amounts back out of benefit_ytd (a row only it made goes), give loan balances back what it paid down, release its time entries; flag later drafts that counted it. 409 for a processed or void run

A reader for QuickBooks Desktop's Payroll Summary waits for a real export to build against.