parsr.

Specialist parser

Payslips to structured JSON, EU-wide

Extract gross pay, deductions, net pay, employer details, and YTD totals from payslips across EU payroll systems. Validates net-pay calculation. Multi-jurisdiction (BE / DE / FR / NL / UK / US), multi-language, EU residency by default.

parse-payslip.shbash
curl -X POST https://eu-api.tryparsr.dev/v1/parse/payslip \
  -H "Authorization: Bearer $PARSR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document_url": "https://example.com/april-2026-sdworx.pdf",
    "wait": 60
  }'

Format coverage

16+ payroll systems

Languages

NL · FR · DE · EN

Avg latency

~2.6s p50

Field accuracy

~92%+ field-level

What we extract

Every field, with confidence and citations

Every field comes back with a confidence score in [0,1] and a normalized bounding box on the source page. Net pay is recomputed from gross minus deductions and surfaced as a validator your agent can branch on.

Input

Anonymized April 2026 SD Worx payslip, page 1 of 1 (Belgian Dutch layout)

Anonymized payslips preview
response.jsonjson
{
  "schema_version": "payslip.v1",
  "result": {
    "employee_name": "Pieter Janssens",
    "employer_name": "Acme NV",
    "employer_address": "Havenlaan 86C, 1000 Brussel, BE",
    "period_start": "2026-04-01",
    "period_end":   "2026-04-30",
    "currency": "EUR",
    "gross_pay": { "amount": "3850.00", "currency": "EUR" },
    "deductions": [
      { "type": "rsz_employee", "label": "RSZ werknemer (13.07%)",          "amount": { "amount": "503.20", "currency": "EUR" } },
      { "type": "income_tax",   "label": "Bedrijfsvoorheffing",             "amount": { "amount": "742.15", "currency": "EUR" } },
      { "type": "meal_voucher", "label": "Maaltijdcheques eigen bijdrage",  "amount": { "amount": "21.78", "currency": "EUR" } }
    ],
    "net_pay": { "amount": "2582.87", "currency": "EUR" },
    "ytd_gross":      { "amount": "15400.00", "currency": "EUR" },
    "ytd_deductions": { "amount": "5068.52",  "currency": "EUR" },
    "ytd_net":        { "amount": "10331.48", "currency": "EUR" },
    "validation": {
      "net_pay_match": {
        "valid": true,
        "computed_net":  "2582.87",
        "declared_net":  "2582.87",
        "diff":          "0.00",
        "tolerance":     "0.01"
      }
    }
  },
  "field_metadata": {
    "net_pay.amount":      { "confidence": 0.98 },
    "deductions.0.amount": { "confidence": 0.96 },
    "employer_name":       { "confidence": 0.99 }
  }
}
FieldTypeDescriptionConf. typical
employee_namestringEmployee's full name as printed. Honors local diacritics and ordering (von / van / de).97%
employer_namestringEmployer legal name. KBO / SIRET / HRB numbers, when present, are surfaced in field_metadata.99%
employer_addressstringSingle-line normalized postal address with country-code suffix.95%
period_start / period_enddate (ISO 8601)Inclusive period covered by the payslip. Mid-month hires return a partial period — see FAQ.98%
currencystring (ISO 4217)Payslip currency. Most EU jurisdictions return EUR; UK returns GBP, US returns USD.99%
gross_paymoney { amount, currency }Gross pay for the period before any statutory or voluntary deductions.97%
deductions[]array of DeductionEach deduction has type (canonicalized — e.g. rsz_employee, lohnsteuer, loonheffing, paye, fica), label (raw line as printed), and amount. Order preserved.94%
net_paymoney { amount, currency }Declared net pay. Cross-checked against gross − Σ deductions and surfaced via validation.net_pay_match.98%
ytd_gross / ytd_deductions / ytd_netmoney { amount, currency }Year-to-date cumulative totals. Returned when the payslip prints them; null otherwise (mid-year hires often omit).95%
validation.net_pay_matchobjectComputed net (gross − Σ deductions) vs declared net, within a 1-cent tolerance for rounding. valid=false flags a typo, missed deduction line, or tampering.100%

Domain-specific validation

What makes this a specialist

Net-pay reconciliation

gross_pay − Σ deductions[].amount = net_pay, computed and returned per payslip, within a 1-cent tolerance for rounding. valid=false typically catches a missed deduction line — the most common OCR error on payslips — before it reaches your reasoning loop.

validation.net_pay_match.valid

examplePayslip declares net 2582.87 EUR; computed from gross 3850.00 minus three parsed deductions is 2604.65. diff: 21.78 — the meal-voucher contribution line was missed by OCR. Validator flags for re-parse / human review.

Deduction-type canonicalization

Per-jurisdiction deduction taxonomies are normalized to stable type slugs. RSZ werknemer (BE) → rsz_employee, Lohnsteuer (DE) → income_tax, loonheffing (NL) → income_tax, PAYE (UK) → income_tax, FICA (US) → social_security. The raw label is preserved for audit.

deductions[].type

exampleUnknown line 'Solidariteitsbijdrage' returned as type='other' with the raw label preserved. Lets you decide whether to add it to your accounting mapping or treat as miscellaneous, rather than silently dropping it.

YTD progression check

When prior-period YTD is provided (via context or a previous parse), validates that ytd_gross_n = ytd_gross_(n-1) + gross_pay_n. Catches duplicate or skipped payroll runs in long-running underwriting pipelines.

validation.ytd_progression.valid

exampleApril YTD gross 15400.00 vs March YTD 11900.00 + April gross 3850.00 = 15750.00. diff: 350.00 — likely a bonus printed on a separate payslip not yet ingested. Surfaced for review rather than averaged silently.

Format coverage

Tested across 16+ EU and US payroll systems

16+ payroll systems · 6 jurisdictions · 4 languages (NL · FR · DE · EN)

Belgium

  • SD Worx
  • Acerta
  • Securex
  • Partena

Germany

  • DATEV
  • SAP HR

France

  • Sage Paie
  • ADP France

Netherlands

  • Loket
  • Loonbedrijf

United Kingdom

  • Sage Payroll
  • Xero Payroll

United States

  • ADP
  • Paychex
  • Gusto
  • Justworks

Code recipes

From document to JSON in five lines

parse.shbash
curl -X POST https://eu-api.tryparsr.dev/v1/parse/payslip \
  -H "Authorization: Bearer $PARSR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "document_url": "https://example.com/april-payslip.pdf",
    "wait": 60
  }'
parse_payslip.pypython
import os, httpx

resp = httpx.post(
    "https://eu-api.tryparsr.dev/v1/parse/payslip",
    headers={"Authorization": f"Bearer {os.environ['PARSR_API_KEY']}"},
    json={
            "document_url": "https://example.com/april-payslip.pdf",
        "wait": 60,
    },
    timeout=70,
)
result = resp.json()["result"]
match = result["validation"]["net_pay_match"]
if not match["valid"]:
    raise ValueError(f"Net pay does not reconcile — diff {match['diff']}")
print(result["employee_name"], result["net_pay"]["amount"], result["currency"])
for d in result["deductions"]:
    print(" -", d["type"], d["amount"]["amount"])
parsePayslip.tstypescript
const resp = await fetch("https://eu-api.tryparsr.dev/v1/parse/payslip", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PARSR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    document_url: "https://example.com/april-payslip.pdf",
    wait: 60,
  }),
});
const { result } = await resp.json();
if (!result.validation.net_pay_match.valid) {
  throw new Error(
    `net pay does not reconcile — diff ${result.validation.net_pay_match.diff}`,
  );
}
console.log(result.employee_name, result.net_pay.amount, result.currency);
agent.pypython
from langchain_parsr import ParsrToolkit
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent

tools = ParsrToolkit.from_env().get_tools()
agent = create_react_agent(ChatOpenAI(model="gpt-4o"), tools)

result = await agent.ainvoke({
    "messages": [(
        "user",
        "Parse this April SD Worx payslip and tell me the take-home pay and "
        "total statutory deductions: https://example.com/april-payslip.pdf"
    )]
})
print(result["messages"][-1].content)

Compared

How parsr's payslip parsing compares

VendorPricing per pageEU residencyConfidence + bboxNet-pay validatorEU payroll-system coverage
parsrfrom €0.022Default (eu-api region-bound key)Per field, per deductionYes — built into response16+ formats tested across BE / DE / FR / NL / UK / US
Mindee~$0.10 (Pro tier)Pro tier+ only (€179/mo entry)YesNoGeneric payslip OCR — no per-system fixtures
ReductoCustomGrowth tier (custom)PartialNoGeneral-purpose parser
DocuPipeQuote-basedYesNoDashboard-driven, custom configs
Veryfi$500/mo minimumNoYesNoUS-focused (ADP, Paychex, Gusto)

Need a new payroll system or layout change? 48 hours.

We don't train models — we curate prompts, schemas, validators, and fixture tests. A new payroll system or a quarterly layout change goes live in two business days. Mindee's pre-trained models take months to add new formats. Email an anonymized sample and we'll confirm within 24 hours.

Request a format →

FAQ

Common questions

  • How do you handle different jurisdictions' deduction codes?

    Each parsed deduction returns both a canonical type (rsz_employee, lohnsteuer, loonheffing, paye, fica, …) and the raw label as printed. Belgian RSZ/ONSS, German Lohnsteuer / Solidaritätszuschlag / Kirchensteuer, Dutch loonheffing, French CSG/CRDS, UK PAYE/NI, and US federal / FICA / state lines are all canonicalized. Unknown lines come back as type='other' with the raw label preserved — never silently dropped.

  • What languages are supported?

    NL, FR, DE, and EN — covering Belgian (NL/FR), Dutch, German, French, UK, and US payslips end-to-end. Trilingual Belgian payslips (NL + FR + DE column headers) are handled out of the box; the canonical deduction type is the same regardless of the printed language.

  • How is year-to-date treated?

    ytd_gross, ytd_deductions, and ytd_net are returned when the payslip prints them. Mid-year hires whose first payslip has no YTD column return null for those fields rather than zero — null is honest, zero is misleading. If you parse a sequence of payslips for the same employee, validation.ytd_progression checks that month-over-month YTD values reconcile.

  • What about a mid-year hire or a partial period?

    period_start and period_end reflect what's printed — a partial period (e.g. 2026-04-15 to 2026-04-30) is returned faithfully. Pro-rated gross_pay is returned as printed; we don't re-derive a full-month equivalent. If a downstream system needs an annualized figure, your code does that math with the period dates as inputs.

  • Scanned vs digital PDF payslips?

    Vision LLMs handle PDF (text or image-based), JPEG, PNG, and HEIC. Digital PDFs hit ~95%+ field accuracy on Tier-1 systems (SD Worx, DATEV, Sage Paie, Loket, ADP) where fixture coverage is deepest. Phone-photo scans work down to ~150 DPI equivalent; deep skew or partial occlusion drops confidence. field_metadata.<field>.confidence < 0.85 is the recommended escalation threshold for review.

  • When should I use doc_type='payslip' vs 'invoice'?

    Use 'payslip' for any employer-issued earnings statement showing gross pay, deductions, and net pay for a worked period. Use 'invoice' for goods or services billed by a vendor. Contractor invoices that look payslip-shaped (e.g. monthly retainer with VAT) should still be parsed as 'invoice' — they don't have the gross/deductions/net structure the payslip schema validates against.

200 free pages. No credit card. No sales call.

Drop payslips parsing into your stack in an afternoon. If it doesn't earn its keep, walk away — no lock-in.

Get an API key