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.
Endpoints
Section titled “Endpoints”GET /api/pays/summary
Section titled “GET /api/pays/summary”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.
GET /api/pays/payslips
Section titled “GET /api/pays/payslips”List payslip summaries for the current user, most recent first.
Permission: view:pays
Query params:
page— 1-based page index (default1, page size25).preset— filter window. Acceptsall(default),this_month,last_month, orfy_<startYear>(Australian financial year, e.g.fy_2025covers2025-07-01–2026-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.
GET /api/pays/payslips/stats
Section titled “GET /api/pays/payslips/stats”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 /api/pays/payslips/:id
Section titled “GET /api/pays/payslips/:id”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" }.
POST /api/pays/refresh
Section titled “POST /api/pays/refresh”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_ytdfor 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 } ]}Scheduled cron
Section titled “Scheduled cron”A scheduled handler runs every 6 hours (alongside the existing Xero invoice cron):
- Phase A — tenant-wide payrun upsert:
fetchPayRuns(1 call) +fetchPayRunper new pay run. All employees’ payslip summaries are upserted withraw_json = NULL— including employees not yet mapped to a Nucleus person (rows land withperson_id = NULLandxero_employee_idset). 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’spay_summaries.earnings_ytdandnext_pay_datestraight from cached payslip rows (no Xero call). - Phase B —
last_increase_atbackfill, budgeted to ~10 Xero calls per firing. Targets people whoselast_increase_atis null and who have ≥ 2 payslips withraw_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 — setsannual_salaryfor 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.
What maintains pay_summaries
Section titled “What maintains pay_summaries”| Field | Interactive /refresh & /summary | Cron Phase A | Cron Phase B | Mapping 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.
Provider Routing
Section titled “Provider Routing”The system checks the current user’s people record in this order:
remote_employment_id→ fetches from Remote APIxero_employee_id→ fetches from Xero Payroll AU API
Remote provides compensation data (annual salary, currency, period) but not individual payslips or YTD earnings.
Cache Strategy
Section titled “Cache Strategy”- TTL: 4 hours
- Stale fallback: If the provider is unavailable, previously cached data is served with
stale: true - Manual refresh:
POST /refreshbypasses TTL and re-fetches from the active provider
Prerequisites
Section titled “Prerequisites”- Xero or Remote connection configured in Settings > Connections
- User’s payroll ID mapped in Settings > Connections > User Mappings (
xero_employee_idorremote_employment_id)