Docs Using SlowBooks

Payroll and HR

For SlowBooks Pro v2.31.4 · Download this guide (one HTML file) · Source on GitHub

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

  • app/static/js/onboarding.js β€” checklist UI with per-task signature
  • app/static/js/time_entries.js β€” entry list + approve/reject buttons
  • app/static/js/pto.js β€” the Time Off page: policies + requests with workflow buttons

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

  • app/static/js/benefits.js β€” codes / groups / enrollments / remittance tabs
  • app/static/js/deductions.js β€” garnishments page (was three sections: types, per-employee, garnishments) with add/remove forms

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

  • app/static/js/tax_forms.js β€” picker for year/quarter, blob-opens the response

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

  • Hard expiry: 1 year from mint, in portal_token_expires_at.
  • Idle expiry: 90 days since portal_token_last_used. Rolled forward on every authenticated request, so an active user never trips it.
  • Expired tokens return 410 Gone. The admin can issue a fresh one via the rotate endpoint.

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:

  • The paycheck's caps (ytd.wage_base_ytd): Social Security from the opening's SS wages, the Additional Medicare threshold from its Medicare wages, FUTA from its FUTA wages, SUTA and the state's capped taxes from the work state's SUTA and state wages (no state rows: its gross stands in for the work state). A stub counts its FICA wages (gross less Section 125 / HSA, payroll_service.stub_taxable_wages) toward Social Security, Additional Medicare, FUTA and SUTA (SUTA in the state it was worked in), and its gross toward the state's capped taxes, which the state engines measure on gross. employee_ytd and the stub's YTD count every quarter.
  • The Social Security wage base is the year's (SS_WAGE_BASES, ss_wage_base(year): 2023 $160,200, 2024 $168,600, 2025 $176,100, 2026 $184,500, 2027 $192,300) for the opening's check, the paycheck and the 941 and W-2. A year with no base here is measured against the nearest one; the opening (warnings) and the pay run (warnings) say so. FUTA's $7,000 is statutory and the same every year.
  • 941: the quarter's own opening figures (the old system filed its finished quarters); earlier quarters fill the wage base and the Additional Medicare threshold.
  • Tax liability schedule: the quarter's opening amounts, as prior_system on each line beside due (what is left to deposit), so the 941 line equals line 12.
  • 940: payments and FUTA tax for the year; FUTA wages fill the $7,000 first.
  • W-2 / W-3: every quarter; boxes 3 and 5 from the taxable wages, 16 and 17 from the state rows. Boxes 15-17 go one line per state (stubs by the state they were worked in, the opening's state rows by theirs; no state rows: the work state). With two or more, compute_w2 carries states; with one it keeps its old shape, so its Form history hash is unchanged. The PDF prints two states per form; a third goes on an additional W-2 with only the identifying boxes and the state lines (w2_forms).
  • State SUI report: the quarter's state rows, by their own state (stubs by the state they were worked in); a stub's SUTA-taxable wages are its FICA wages up to what is left of that state's base.
  • Form history Check: a W-2, W-3, 940 or 941 generated before 2.29.0 was figured with each stub's gross toward the bases, one state line and the 2026 base for every year. When today's figures differ, Check re-figures it that way too (legacy=True); the same hash means the pay runs are as they were (matches, figured_before_2_29), and the sentence says the form comes out differently now.
  • Not counted: benefit remittances, the pay-run list, NACHA and the payroll journal.

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.


  • Security hardening β€” the production-readiness pass that hardened the portal, encryption, and HTTPS layers
  • ../SECURITY.md β€” public security policy / responsible disclosure