Payroll is not a hard calculation. Multiply, look up a bracket, subtract. Any engineer can build something that returns a number for a paycheck in an afternoon, and it will look right.
That is the whole problem. A wrong paycheck looks exactly like a right one. There is no exception, no red row in a log, no failing test — just a number that is a little too small, on a stub nobody reads closely, until the W-2 does not reconcile in February and somebody has to work out which of the last twenty-six pay periods was wrong.
We built the Geoverio Payroll API around that failure mode rather than around the calculation. Here are five places the math goes quietly wrong, and what we do at each one.
1. The annual boundary in the middle of a pay period
Two of the biggest federal figures are annual, not per-paycheck. In 2026 Social Security is 6.2% of wages up to a $184,500 wage base, and it stops there. Additional Medicare is 0.9% on wages above $200,000, and it starts there — with no employer match.
Both boundaries are crossed inside a pay period, not between them. Whether this Friday's paycheck is right depends on every paycheck before it in the year, and if you calculate period 30 without periods 1 through 29, you will either keep withholding Social Security past the cap or start the surtax late. Both errors produce a completely plausible number.
So we made the dependency explicit in the API surface. There are two ways to calculate many paychecks and they are deliberately not interchangeable:
- POST /v1/paycheck/batch — many employees, one pay period. The employees are independent of each other, so they run side by side, up to 200 in a call.
- POST /v1/paycheck/series — one employee, many pay periods. The periods are not independent, so the engine runs them in order and carries year-to-date forward from each into the next.
Running this Friday's payroll for forty people is a batch call. Reconstructing a year of pay stubs for one person is a series call. If you reach for the wrong one, the wage-base crossing lands on the wrong paycheck — which is exactly the bug we did not want to leave available.
The series endpoint also generates the periods the way the calendar actually falls, rather than dividing a year into equal slices: semimonthly is the 1st to the 15th and the 16th to the end of the month, quarters start in January, and a period that has not finished yet is never generated at all.
2. The silent zero
Ask most payroll calculators for a state they do not really support and you will get a paycheck back. State withholding will be 0.00. There will be no error, because zero is a perfectly valid amount of tax — nine states genuinely have no wage income tax at all.
This is the single most dangerous behaviour in a payroll engine, and it is common because returning zero is easier than admitting a gap.
Our engine refuses. A jurisdiction whose tables are not sourced returns a structured 422 that names exactly what is missing — not a generic failure, but which piece of that jurisdiction's method the engine does not have. You cannot ship a wrong paycheck through it by accident, because it will not produce one.
Two consequences we thought worth building around it. First, GET /v1/jurisdictions is a real endpoint, not a marketing page: it reports every jurisdiction as supported, partial or unsupported, with the withholding method used and the official source behind it, so you can check before you build rather than after a customer complains. Second, in a batch run one unsupported employee does not sink the run — that employee comes back in an errors list with the reason, and the other 199 still calculate.
3. The table that changed in July
It is tempting to file tax tables by year: one folder per year, pick the folder, done. But jurisdictions do not cooperate with that. Several publish more than one table set inside a single year — a rate change effective mid-year, a revised set after a legislative session.
Select tables by calendar year and a December pay date gets the January table. The result is off by a few percent and entirely believable.
So every table set in the engine carries effective_from and effective_to dates, and the pay date selects it — not the year, not the period end, not the current date on the server. A back-dated correction run in September for a pay date in May uses May's tables, which is the only answer that reconciles with what was actually withheld.
4. Pre-tax is not one thing
Here is the edge that catches almost everyone. "Pre-tax deduction" sounds like a single concept: subtract it from gross before calculating tax. In reality every deduction type has its own answer for every tax base, and the answers disagree.
Two real examples from our 2026 data:
- Pennsylvania taxes 401(k), 403(b), 457(b) and SIMPLE IRA contributions at the state level — they reduce federal taxable wages and do not reduce Pennsylvania's. Section 125 contributions are excluded. So a Pennsylvania employee deferring into a 401(k) has a state taxable wage higher than their federal one, at a flat 3.07%.
- California does not conform to the federal treatment of HSA contributions: they are excluded federally and taxable for California, while Section 125 and 401(k) are excluded in both.
An engine that carries one "pre-tax" flag per deduction gets both of these wrong, and gets them wrong in the direction that under-withholds — which the employee discovers at filing time.
In our data, deduction treatment is a per-jurisdiction table, not a global rule: each jurisdiction declares how it treats each deduction type, defaulting to the federal treatment only where it genuinely follows it. The boundary test suite covers the divergences directly, including the Pennsylvania add-back and California's taxable HSA and uncapped SDI.
5. A correct paycheck on an unlawful stub
The last one is not arithmetic at all. Getting the numbers right and handing the employee a lawful pay statement are two different jobs, and passing the first does not get you the second.
No federal statute prescribes what a pay statement must contain. Most states do, and they differ. A stub can be perfectly accurate and still be missing an item the employee's state requires on it.
So every paycheck response returns the jurisdiction's required statement items alongside the numbers, and POST /v1/statement/validate takes a statement you have built and tells you which required items are present and which are still missing. The rendering stays yours — we do not want to own your template — but the compliance metadata comes with the calculation instead of being something you find out about later.
How we know any of this is right
Two things, and neither is "we tested it".
Every number is traceable. Each figure in the engine has a manifest entry recording the official source URL, the date it was retrieved and how it was verified — for the federal percentage-method tables, that every bracket's tentative amount was re-derived arithmetically from the IRS publication's own numbers, in all three filing statuses and both schedules. There are 62 such entries behind the 2026 data. The rule is written into the data file itself: a number without a manifest entry does not exist.
The tests are the jurisdictions' own homework. 63 golden cases, each one a worked example printed in a taxing authority's own publication — the IRS Publication 15-T method, Illinois's IL-700-T booklet, and so on — plus a boundary suite for the classic payroll bugs: the wage-base crossing, the Additional Medicare threshold, the year boundary, W-4 floors, the divergence between 401(k), Section 125 and Roth, and the zero-or-negative-net guard.
Golden cases are data rather than code, so the same file that runs in the test suite also populates the coverage matrix in our own admin panel. When a jurisdiction publishes new tables, the case it ships with them is the test.
What it deliberately does not do
It does not store anything. Year-to-date is an input and an output; no employer, employee or paycheck record is kept and request bodies are never logged. That is a design decision rather than a limitation — it means the API can sit inside your product without becoming a second system of record you have to secure, audit and delete from.
It does not render documents. The JSON is structured so a renderer is trivial, but the template is yours.
And it does not file or remit. It returns numbers; nothing is transmitted to any tax authority and no money is held.
Narrow on purpose. There is more than enough to get wrong inside those boundaries.
Try it
The quickest look is GET /v1/paycheck/quick — a take-home estimate from query parameters, one line of curl. When you want the real thing, POST /v1/paycheck takes the full input and returns the statement with it. The reference is at /docs/payroll, and the same key works across every Geoverio API on your plan.
Before you build on a particular state, call GET /v1/jurisdictions first. It is the most useful endpoint we have, and it is the one that tells you what we cannot do.