Payslips
Submit payslips for compliance evaluation and read back the results. All routes
are under /api/v1 and require a bearer token.
POST /payslips — submit
Accepts three payload shapes. Every successful submission returns 202 Accepted
and is evaluated asynchronously.
1. Structured payslip
Send a payroller-style PayslipView ({ company, payRun, employee }) or a bare
employee object. Wrap it under payslip, or post it at the top level:
curl -X POST https://your-host/api/v1/payslips \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pay-2024-06-emp-42" \
-d '{
"payslip": {
"company": { "name": "Acme Pty Ltd" },
"payRun": { "periodStart": "2024-06-03", "periodEnd": "2024-06-09",
"paymentDate": "2024-06-13" },
"employee": {
"award": "MA000038",
"classificationId": "1483",
"earnings": [
{ "description": "Ordinary hours", "hours": 38, "rate": 29.50 }
]
}
}
}'
2. Bulk
Send an array under payslips. Each item is ingested independently with its own
derived idempotency key (the Idempotency-Key header is ignored for batches):
{ "payslips": [ { "employee": { } }, { "employee": { } } ] }
3. Document upload
Send a payslip file as multipart form data under file (or document):
curl -X POST https://your-host/api/v1/payslips \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@payslip.pdf" \
-F "hours=Mon-Fri 8h, Sat 4h"
- Accepted content types:
application/pdf,image/png,image/jpeg,image/tiff. The true type is sniffed from the bytes, not the filename. - Maximum size: 15 MB (
413if exceeded;422for an unsupported type). - Text is extracted locally (native PDF text layer, or on-host OCR for scans); no document image ever leaves the host, and only PII-filtered text is used for structuring.
redaction(optional form field) —client_drawsays you blacked out parts of the image before sending it (the FairCheck browser upload does this when the uploader ticks "Redact in my browser first"). It is recorded as provenance on the payslip; the bytes are processed exactly as any other scan. FairCheck cannot verify a drawn redaction, only record that you claimed it.
3b. Pre-redacted text
If you extract and redact the payslip text yourself, send the text instead of
the file. FairCheck stores no document at all for such a payslip: the text is
the source, encrypted at rest and dropped as soon as it has been structured.
curl -X POST https://your-host/api/v1/payslips \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "pages": ["...page 1 text...", "...page 2 text..."],
"filename": "payslip.pdf", "redaction": "client",
"hours": "38 ordinary + 4 overtime" }'
pages— array of page strings, in order. Alternativelytext: one string with pages separated by a form feed (\f). Several payslips in one export are split by page exactly as they are for a file.redaction—clientif you removed personal data before sending, otherwise omit it (recorded asnone). The on-host PII filter runs either way.filename— optional label kept with the payslip.hours— free-text hours worked, as for a file upload.- Limits: 2 MB of text, 250 pages, and at least 40 characters per page (
422for empty text,413when over the limits). - Idempotent on the text's hash, or on an
Idempotency-Keyheader if you send one. The response is the document-upload envelope below (202).
Optional: hours worked & award
Two convenience fields help the engine check the pay against what was actually
worked and which instrument applies:
hours— the hours the employee actually worked, checked worked-vs-paid. On a document upload this is free text (e.g."38 ordinary + 4 overtime"), normalised to a weekly bucket during processing. On a structured submission pass a bucket object instead —{ "ordinary": 38, "overtime": 4, "saturday": 0, "sunday": 0, "public_holiday": 0 }— which is merged intoemployee.workedHours(you may also setemployee.workedHoursdirectly).award/classification_id— top-level shortcuts merged into theemployeeobject when it doesn't already carry them. Explicit values in the payload always win.
{ "payslip": { "employee": { "employeeId": "emp-42" } },
"award": "MA000038", "classification_id": "1483",
"hours": { "ordinary": 38, "overtime": 4 } }
Hours-worked evidence enables the entitlement check (worked hours priced against
the instrument floor vs. the gross actually paid). Without it, worked-hours rules
are skipped rather than guessed.
If you omit hours and the payslip matches an employee who has a typical week
recorded (set on their record in the web app), that pattern is used instead, and
the finding says so. Where those hours are marked as the employee's exact roster
they're treated like evidence you supplied; where they're only an indication, a
shortfall against them is reported as a warning with "shortfall": 0 — enough
to review, never a claimed underpayment. Hours sent with the payslip always win.
Response
{
"data": [
{ "id": "payslip_abc123", "status": "pending", "employee_ref": "emp-42",
"pay_run_ref": "run-88", "links": { "self": "/api/v1/payslips/payslip_abc123" } }
],
"meta": { "count": 1, "created": 1 }
}
meta.created counts genuinely new payslips — a 0 means an idempotent match
returned an existing record.
employee_ref echoes back the employeeId you submitted. FairCheck stores it
pseudonymised; after the payslip passes its retention window and its personal
data is purged, reads return an opaque PII_… token in its place.
GET /payslips/:id — read a result
Returns the payslip plus its latest compliance run once evaluation has finished.
{
"data": {
"id": "payslip_abc123",
"status": "completed",
"employee_ref": "emp-42",
"pay_run_ref": "run-88",
"award_code": "MA000038",
"classification_fixed_id": "1483",
"period_start": "2024-06-03",
"period_end": "2024-06-09",
"pay_date": "2024-06-13",
"worked_hours": { "ordinary": 38.0, "overtime": 4.0 },
"compliance": {
"rule_engine_version": "2.1.0",
"summary_status": "needs_review",
"status": "needs_review",
"evaluated": true,
"evaluated_at": "2024-06-13T04:21:00Z",
"findings": [
{ "code": "MINIMUM_WAGE", "severity": "error", "shortfall": 42.30,
"title": "Base rate below award minimum", "message": "…" }
]
}
},
"errors": null,
"meta": {}
}
award_code and classification_fixed_id report the pair the check was actually
priced against. That is the payslip's own pair when the submission carried one,
and otherwise the employee's — an award set on a person applies to every payslip
of theirs, including ones already ingested. Neither field is an echo of what you
submitted; submit them again on the next payslip if you want them pinned to it.
status—pending,processing,held,completed, orfailed.hold_reason— set only whenstatusisheld. A held payslip is stored but has not been checked, andcomplianceisnull:no_seat: the employee has no seat on your plan and none were free. It is checked automatically once one is (more seats, or a seat freed).unmatched: the payslip couldn't be matched to an employee, so it can't be counted against one. Link it to an employee in the app to check it. FairCheck charges per employee checked; see Billing in the app.
worked_hours— the normalised hours-worked bucket used for the entitlement check (nullwhen none was supplied).complianceisnulluntil an evaluation exists.summary_status—passed,needs_review, orfailed. The stored verdict: whether any rule failed. Stable, and unchanged since v1.status— what a reader should be shown, and the one to automate on. Everythingsummary_statuscan be, plus:not_checked— no award could be resolved for this person, so the wage rules never ran.summary_statusreportspassedhere because nothing failed, which is true and thoroughly misleading on its own. Confirm the award and classification, then re-check.cant_verify— the rate covers hours we cannot place (a flat or all-inclusive rate with no breakdown), so whether it is correct is undecidable from the payslip alone.
evaluated—falsewhen no evaluation ran at all.findings— the failing rule results (each with a stablecode,severity, optionalshortfall, and an explanatorymessage). An empty array withstatus: "passed"means no issues were detected.
Treat
status, notsummary_status, as the verdict. A payslip we could not
match to an award hassummary_status: "passed"andstatus: "not_checked"—
filing the first as a clean result records an all-clear for a payslip whose
wages were never examined.Not every underpayment is detectable from a single payslip (e.g. shaved hours or
under-accrued leave). Apassedresult means nothing was detected in the data
provided — not a legal guarantee.
Amounts
Where a payslip carries hours, the period entitlement is the authoritative
figure and the per-line rate findings are its breakdown: they keep their
expected and actual and carry shortfall: 0, with a Counted in evidence
row. Summing every finding's shortfall therefore gives the amount owed once,
not once per rule that noticed it.
ai_review (optional)
Accounts with the AI review layer enabled also receive an ai_review key,
alongside compliance and never inside it. The key is absent for everyone
else, so existing integrations are unaffected.
"ai_review": {
"status": "flagged",
"reasons": [
{ "code": "hours_inconsistent_with_payslip",
"message": "This payslip suggests a different working pattern than the hours we assumed." }
]
}
status—flaggedorclear.reasons— zero or more entries drawn from a closed set ofcodevalues, each with fixed explanatory text.
compliance remains the verdict of record. The AI layer is a second opinion
that runs alongside the deterministic engine: it can flag a payslip as worth a
second look, but it never changes summary_status, never adds a finding, and
never produces an amount. A payslip whose compliance.summary_status is
passed and whose ai_review.status is flagged was not found to be short —
something about it merited a human glance.
Automate against compliance. Treat ai_review as a prompt for review.
GET /payslips — list
Paginated list of the account's payslips (most recent first).
{
"data": [ { "id": "payslip_abc123", "status": "completed", "links": { } } ],
"errors": null,
"meta": { "page": 1, "pages": 4, "count": 80 }
}