Skip to content

Pays API

All pay endpoints are provider-aware: they detect whether the current user is mapped to Remote (via remote_employment_id) or Xero (via xero_employee_id), and fetch data from the appropriate provider.

Get the current user’s pay summary: annual salary, earnings YTD, next pay date.

Permission: view:pays

Response:

{
"annual_salary": 120000,
"earnings_ytd": 65000,
"next_pay_date": "2026-03-13",
"pay_calendar": "Fortnightly",
"fy_label": "FY25-26",
"provider": "xero",
"fetched_at": "2026-03-02T10:00:00"
}

For Remote employees, pay_calendar shows the employee’s country (e.g. "Remote · Australia") and provider is "remote". Remote does not provide YTD earnings or next pay date, so those may be null.

Returns error string and null values if no payroll mapping exists. Returns stale: true if serving cached data because the provider is unavailable.

List payslip summaries for the current user, most recent first.

Permission: view:pays

Query params:

  • page — 1-based page index (default 1, page size 25).
  • preset — filter window. Accepts all (default), this_month, last_month, or fy_<startYear> (Australian financial year, e.g. fy_2025 covers 2025-07-012026-06-30).

Response:

{
"payslips": [
{
"id": "xero-payslip-id",
"pay_run_id": "xero-payrun-id",
"period_start": "2026-02-17",
"period_end": "2026-02-28",
"payment_date": "2026-03-01",
"gross_earnings": 4615.38,
"net_pay": 3450.00,
"tax": 965.38,
"super": 553.85,
"fetched_at": "2026-03-02T10:00:00"
}
],
"provider": "xero"
}

For Remote employees, returns { "payslips": [], "provider": "remote" } — Remote does not expose individual payslips via API. The UI shows a message directing users to the Remote platform.

Aggregated stats across the current user’s payslips, scoped by preset. Used by the pays page to render the summary sidebar and the dynamic financial-year segments.

Permission: view:pays

Query params: preset — same values as /payslips.

Response:

{
"totalAll": 64,
"totals": { "count": 24, "gross": 110700, "net": 82750, "tax": 27950, "super": 12677 },
"byPeriod": [
{ "period": "2026-04", "net": 6900, "gross": 9230 }
],
"availableFinancialYears": [
{ "value": "fy_2025", "label": "FY 25/26", "startYear": 2025 },
{ "value": "fy_2024", "label": "FY 24/25", "startYear": 2024 }
]
}

availableFinancialYears is derived from the user’s payslip history and is independent of the preset query param — it lists every AU FY the user has at least one payslip in, newest first.

Get full payslip detail including earnings lines, deductions, tax, superannuation, and leave accruals. Only available for Xero employees.

Behaviour: The first time a payslip detail is requested, the endpoint calls Xero live (fetchPayItems + fetchEmployee + fetchPayslipDetail ≈ 4 Xero calls), enriches the response, and caches it on the payslips.raw_json column. Subsequent requests for the same payslip return from D1 with zero Xero calls. If the live fetch fails (rate limit, network error), the response is returned with detail: null and an error string.

Permission: view:pays

Response:

{
"payslip": {
"id": "xero-payslip-id",
"period_start": "2026-02-17",
"period_end": "2026-02-28",
"payment_date": "2026-03-01",
"gross_earnings": 4615.38,
"net_pay": 3450.00,
"tax": 965.38,
"super": 553.85
},
"detail": {
"PayslipID": "xero-payslip-id",
"EmployeeID": "xero-employee-id",
"FirstName": "Brendon",
"LastName": "Smith",
"NetPay": 3450.00,
"Tax": 965.38,
"EarningsLines": [
{ "Name": "Ordinary Hours", "Amount": 4615.38, "NumberOfUnits": 76, "RatePerUnit": 60.73 }
],
"DeductionLines": [],
"SuperannuationLines": [
{ "ContributionType": "SGC", "Amount": 553.85 }
],
"LeaveAccrualLines": [
{ "Name": "Annual Leave", "NumberOfUnits": 5.85 }
]
}
}

For Remote employees, returns { "payslip": null, "provider": "remote" }.

Force re-fetch all pay data from the payroll provider (Xero or Remote), bypassing the 4hr cache.

Xero behaviour: Pulls only the PayRuns endpoint and reads the per-employee summary fields (Wages, Deductions, Tax, Super, Reimbursements, NetPay) nested in PayRuns[].Payslips[]. No Payslip/{id} calls are made during sync — line items are deferred to GET /payslips/:id. Cost: 1 + (new payruns) Xero calls. Writes payslip rows for every employee in each pay run (mapped or not) — unmapped rows land with person_id = NULL and xero_employee_id set, ready to be claimed by a future mapping.

Permission: view:pays

Response: { "ok": true } or { "error": "message" } on failure.

POST /api/admin/connections/xero/backfill-payslips

Section titled “POST /api/admin/connections/xero/backfill-payslips”

One-shot, tenant-wide capture of all POSTED Xero pay runs into the payslips table. Use it to seed a fresh install or a tenant with deep history, rather than waiting for the cron’s incremental Phase A to reach old runs (it never will — Phase A’s If-Modified-Since cursor only looks forward).

Permission: admin (this route group is gated by requireAdmin()).

Behaviour:

  • Fetches the full pay-run list (no If-Modified-Since) so older, never-captured runs are always considered.
  • Processes newest → oldest (sorted by payment date descending), so the most recent — most valuable — pay runs land first.
  • For each run not already in the DB, fetches it once and upserts a payslip row per employee — mapped or not (unmapped rows land with person_id = NULL).
  • Commits in batches of 25 as it goes, so if the Worker hits its time limit mid-backfill the captured runs persist and a re-run resumes from where it left off — continuing with progressively older runs (already-captured ones are skipped).
  • Recomputes earnings_ytd for affected people at the end.

Response:

{ "ok": true, "newRuns": 142, "captured": 980, "unmapped": 120, "peopleUpdated": 18 }

For a tenant with many years of history, the first call may not finish within a single Worker invocation — recent pay is captured first, and you just call it again to pull older history until newRuns returns 0.

There’s an admin UI for this at /employment/pays/backfill — a “Run backfill” button plus a table of captured pay runs with per-run mapped/unmapped counts. It’s gated to admins.

GET /api/admin/connections/xero/payslip-coverage

Section titled “GET /api/admin/connections/xero/payslip-coverage”

Backs the backfill admin UI. Returns a tenant-wide summary plus per-pay-run capture counts (aggregated from the payslips table — there is no separate pay-runs table).

Permission: admin.

Response:

{
"summary": { "runs": 142, "payslips": 980, "mapped": 860, "unmapped": 120, "enriched": 40 },
"runs": [
{
"pay_run_id": "",
"payment_date": "2026-05-15",
"period_start": "2026-05-01",
"period_end": "2026-05-14",
"total": 7,
"mapped": 6,
"unmapped": 1,
"enriched": 2
}
]
}

A scheduled handler runs every 6 hours (alongside the existing Xero invoice cron):

  • Phase A — tenant-wide payrun upsert: fetchPayRuns (1 call) + fetchPayRun per new pay run. All employees’ payslip summaries are upserted with raw_json = NULL — including employees not yet mapped to a Nucleus person (rows land with person_id = NULL and xero_employee_id set). An idempotent self-heal claim runs at the start of the phase to attach any unmapped rows whose mapping was created since the last firing. After capture it recomputes each affected person’s pay_summaries.earnings_ytd and next_pay_date straight from cached payslip rows (no Xero call).
  • Phase Blast_increase_at backfill, budgeted to ~10 Xero calls per firing. Targets people whose last_increase_at is null and who have ≥ 2 payslips with raw_json IS NULL. Fetches detail for their two newest payslips, enriches, persists, runs the salary-change detector, and — since it already holds the employee record — sets annual_salary for free.

Phase B converges over a few cron firings for a fresh tenant. Steady-state Phase B cost is ~1 extra call per employee per payday.

FieldInteractive /refresh & /summaryCron Phase ACron Phase BMapping claim
earnings_ytd
next_pay_date
annual_salary
pay_calendar
last_increase_at

earnings_ytd and next_pay_date need no Xero call (derived from cached payslips), so the cron keeps them current on its own. annual_salary and pay_calendar require the Xero employee record: annual_salary is filled opportunistically by Phase B, and both are fully set by any interactive /refresh. A person captured by the cron before they’ve ever opened the Pays page will therefore show YTD + next-pay-date immediately, with salary following once Phase B reaches them or they load the page.

The system checks the current user’s people record in this order:

  1. remote_employment_id → fetches from Remote API
  2. xero_employee_id → fetches from Xero Payroll AU API

Remote provides compensation data (annual salary, currency, period) but not individual payslips or YTD earnings.

  • TTL: 4 hours
  • Stale fallback: If the provider is unavailable, previously cached data is served with stale: true
  • Manual refresh: POST /refresh bypasses TTL and re-fetches from the active provider
  • Xero or Remote connection configured in Settings > Connections
  • User’s payroll ID mapped in Settings > Connections > User Mappings (xero_employee_id or remote_employment_id)