BackOffice¶
The BackOffice resource scrapes the Sonny's BackOffice web UI (the
manager portal, e.g. https://washu.sonnyscontrols.com) to retrieve
timeclock data far faster than the Data API path can. The BackOffice
employee-timesheets report returns every employee across every site for
an entire month in a single authenticated page load, whereas
client.stats.total_labor_cost() must iterate every employee in 14-day
windows against the API rate limit.
Backed by the BackOffice web UI (NOT the Data API) — requires the
separate backoffice_username / backoffice_password credentials at
client construction.
When to use this vs. stats.total_labor_cost()
Use client.backoffice.timeclock() when you need fast, bulk,
per-shift detail — especially for multi-site operators or date
ranges of a week or more. Use client.stats.total_labor_cost()
when you only need aggregated totals and you don't want a second
credential set. Both methods coexist; neither replaces the other.
Credentials¶
The BackOffice scraper requires two additional credentials on top of the regular API ID and key, passed at client construction:
from sonnys_data_client import SonnysClient
with SonnysClient(
api_id="washu", # doubles as BackOffice subdomain
api_key="your-api-key",
backoffice_username="your.manager.login",
backoffice_password="your-backoffice-password",
) as client:
result = client.backoffice.timeclock("2026-03-01", "2026-03-31")
api_id doubles as the BackOffice subdomain
The BackOffice base URL is https://{api_id}.sonnyscontrols.com.
For a WashU site the api_id is "washu" and the BackOffice login
lives at https://washu.sonnyscontrols.com/login. Pass your tenant
identifier as the api_id argument — same value you already use for
the Data API.
BackOffice credentials are NOT the same as API credentials
The BackOffice user is a manager-portal account that can log in
to the web UI. It is a different credential set from
X-Sonnys-API-ID / X-Sonnys-API-Key. Provision a dedicated
BackOffice administrator for the scraper and store its username and
password in the same secret manager you use for the API credentials.
Backwards compatibility
Existing code that constructs SonnysClient(api_id, api_key) without
BackOffice credentials continues to work unchanged. Any attempt to
call a BackOffice method without having supplied the BackOffice
credentials raises :class:BackOfficeCredentialsError with the
missing field names.
Methods¶
timeclock(start, end, *, site_id=None) -> BackOfficeTimeclockResult
¶
Scrape the /report/employee-timesheets page for a date range and
return per-shift punch-in/punch-out detail grouped by employee, plus a
period grand total parsed from the report's footer row.
result = client.backoffice.timeclock("2026-03-01", "2026-03-31")
print(f"Employees: {len(result.employees)}")
print(f"Total wages: ${result.total_wages:,.2f}")
print(f"Total hours: {result.total_regular_hours + result.total_overtime_hours:,.2f}")
for emp in result.employees[:5]:
adp = f" [ADP:{emp.adp_id}]" if emp.adp_id else ""
print(f" #{emp.employee_number} {emp.employee_name}{adp}")
for shift in emp.shifts:
print(
f" {shift.date_in} {shift.time_in} - {shift.time_out} "
f"@ {shift.site_code} ({shift.regular_hours:.2f}h, "
f"${shift.total_wages:,.2f})"
)
Arguments:
| Parameter | Type | Description |
|---|---|---|
start |
str \| date \| datetime |
Inclusive start (ISO YYYY-MM-DD or a date/datetime). |
end |
str \| date \| datetime |
Inclusive end. |
site_id |
int \| None |
Optional numeric BackOffice site id. When provided, the scraper narrows the report at the URL level (?siteId=...). When None (default), the report includes all sites. |
site_code is ignored here
The client-level site_code header filter does not apply to
the BackOffice path. Every shift in the returned result already
carries its own site_code, so filter client-side on
TimesheetShift.site_code if you need a subset, or pass an
explicit numeric site_id to let the server narrow the report for
you.
Return types¶
BackOfficeTimeclockResult¶
| Field | Type | Description |
|---|---|---|
period_start |
str |
ISO YYYY-MM-DD start of the reporting period |
period_end |
str |
ISO YYYY-MM-DD end of the reporting period |
employees |
list[EmployeeTimesheet] |
Per-employee rollups |
total_regular_hours |
float |
Grand total regular hours (from the report's Timesheet Total: footer) |
total_regular_wages |
float |
Grand total regular wages |
total_overtime_hours |
float |
Grand total overtime hours |
total_overtime_wages |
float |
Grand total overtime wages |
total_wages |
float |
Grand total wages |
EmployeeTimesheet¶
| Field | Type | Description |
|---|---|---|
employee_name |
str |
Last, First (e.g. "Almaliki, Moustafa") |
employee_number |
int |
The #NNN identifier from the report |
adp_id |
str \| None |
ADP ID when the header shows , ADP ID: NNNN |
shifts |
list[TimesheetShift] |
All shifts for this employee in the period |
total_regular_hours |
float |
Employee rollup regular hours |
total_regular_wages |
float |
Employee rollup regular wages |
total_overtime_hours |
float \| None |
None when the employee is not OT-eligible (n/a on the report) |
total_overtime_wages |
float \| None |
None when not OT-eligible |
total_wages |
float |
Employee rollup total wages |
TimesheetShift¶
Each row on the BackOffice report corresponds to one clock-in / clock-out shift. Employees can have multiple shifts per day, optionally at different sites.
| Field | Type | Description |
|---|---|---|
date_in |
str |
ISO YYYY-MM-DD clock-in date |
time_in |
str |
Local time string, e.g. "2:10 PM" |
date_out |
str \| None |
ISO YYYY-MM-DD clock-out date. None for open shifts (employee still on the clock — see below). |
time_out |
str \| None |
Clock-out local time string. None for open shifts. |
timezone |
str |
Timezone abbreviation (e.g. "CST") from the report |
site_code |
str |
Site the shift was worked at |
regular_rate |
float |
Hourly rate for regular hours (0.0 when the cell shows $0.00) |
regular_hours |
float |
Regular hours worked (partial-to-now for open shifts) |
regular_wages |
float |
Regular hours × regular rate |
overtime_rate |
float \| None |
None when n/a (not OT-eligible) |
overtime_hours |
float \| None |
None when n/a |
overtime_wages |
float \| None |
None when n/a |
total_wages |
float |
Shift total wages |
was_modified |
bool |
True for rows with the yellow "modified" warning class |
was_created_in_back_office |
bool |
True for rows that a manager added manually from Back Office |
comment |
str \| None |
Audit comment from the preceding addon-row-comment row (e.g. "Unable to punch in...") |
is_open |
bool (property) |
Convenience: True when date_out is None (employee still clocked in at the moment the report was rendered) |
Empty results
When the requested date range has zero clock entries, BackOffice
replaces the entire report with a single "No clock entries found
matching the given criteria." message. The scraper detects this and
returns a :class:BackOfficeTimeclockResult with an empty
employees list and all totals set to 0.0. period_start
and period_end echo the caller's requested range (since the
server does not surface them on the empty page). The safest
no-results check is:
result = client.backoffice.timeclock(start, end)
if not result.employees:
print("No punches in this range.")
Open shifts (still clocked in)
When timeclock() is called for a date range that includes today,
employees who are still on the clock appear as open shifts —
BackOffice renders a literal "-" in the Date Out and Time Out
columns and the scraper surfaces that as date_out=None /
time_out=None. The BackOffice report still accumulates
partial hours and wages up to the moment the page was rendered, so
regular_hours, regular_wages, and total_wages are all
populated and the employee-level / period-level rollup totals
include them. Check shift.is_open to branch on "finished vs. in
progress":
for emp in result.employees:
for shift in emp.shifts:
if shift.is_open:
print(f"{emp.employee_name} is still on the clock at "
f"{shift.site_code} (in at {shift.time_in}, "
f"{shift.regular_hours:.2f}h so far)")
Errors¶
All BackOffice errors inherit from :class:BackOfficeError (itself a
subclass of :class:SonnysError).
| Exception | Raised when |
|---|---|
BackOfficeCredentialsError |
backoffice_username and/or backoffice_password were not supplied at client construction. The message names the missing field(s). |
BackOfficeLoginError |
The BackOffice login form rejected the credentials, or the login endpoint was unreachable. |
BackOfficeScrapeError |
The timesheets page was reached but the HTML did not match the expected structure (period header / employee blocks / Timesheet Total: footer). Typically indicates a BackOffice UI change. |
from sonnys_data_client import (
SonnysClient,
BackOfficeCredentialsError,
BackOfficeLoginError,
BackOfficeScrapeError,
)
with SonnysClient(api_id="washu", api_key="key") as client:
try:
client.backoffice.timeclock("2026-03-01", "2026-03-31")
except BackOfficeCredentialsError as e:
print("Missing BackOffice credentials:", e)
except BackOfficeLoginError as e:
print("BackOffice login failed:", e)
except BackOfficeScrapeError as e:
print("BackOffice HTML parse failed:", e)
Examples¶
Monthly report with per-employee breakdown¶
from sonnys_data_client import SonnysClient
with SonnysClient(
api_id="washu",
api_key="your-api-key",
backoffice_username="your.login",
backoffice_password="your-password",
) as client:
result = client.backoffice.timeclock("2026-03-01", "2026-03-31")
print(f"{result.period_start} -> {result.period_end}")
print(f"Grand total: ${result.total_wages:,.2f} across {len(result.employees)} employees")
print()
top_earners = sorted(
result.employees, key=lambda e: e.total_wages, reverse=True
)[:10]
for emp in top_earners:
shifts_count = len(emp.shifts)
sites = {s.site_code for s in emp.shifts}
print(
f"{emp.employee_name:<30} "
f"${emp.total_wages:>10,.2f} "
f"{emp.total_regular_hours:>6.2f}h "
f"{shifts_count} shifts at {', '.join(sorted(sites))}"
)
Who is on the clock right now?¶
from sonnys_data_client import SonnysClient
from datetime import date
today = date.today().isoformat()
with SonnysClient(
api_id="washu", api_key="key",
backoffice_username="...", backoffice_password="...",
) as client:
result = client.backoffice.timeclock(today, today)
on_clock = [
(emp, shift)
for emp in result.employees
for shift in emp.shifts
if shift.is_open
]
print(f"{len(on_clock)} employees currently on the clock:")
for emp, shift in on_clock:
print(
f" {emp.employee_name:<30} @ {shift.site_code:<8} "
f"in at {shift.time_in} ({shift.regular_hours:.2f}h so far)"
)
Detect suspicious punches¶
with SonnysClient(
api_id="washu", api_key="key",
backoffice_username="...", backoffice_password="...",
) as client:
result = client.backoffice.timeclock("2026-03-01", "2026-03-31")
# Modified punches and back-office-created entries are worth reviewing
for emp in result.employees:
for shift in emp.shifts:
if shift.was_modified or shift.was_created_in_back_office:
tag = "MODIFIED" if shift.was_modified else "BO-CREATED"
print(
f"[{tag}] {emp.employee_name} on {shift.date_in} "
f"@ {shift.site_code}: {shift.comment}"
)
Filter a single-site slice client-side¶
with SonnysClient(
api_id="washu", api_key="key",
backoffice_username="...", backoffice_password="...",
) as client:
result = client.backoffice.timeclock("2026-03-01", "2026-03-31")
joliet_shifts = [
shift
for emp in result.employees
for shift in emp.shifts
if shift.site_code == "JOLIET"
]
total = sum(s.total_wages for s in joliet_shifts)
print(f"JOLIET: {len(joliet_shifts)} shifts, ${total:,.2f}")
Performance¶
For a mid-size operator (~80 employees, ~15 sites) over a 31-day month:
| Approach | Typical wall time | Rate-limit pressure |
|---|---|---|
client.stats.total_labor_cost() |
several minutes (N_emp × ceil(days/14) API calls) | High — 20 req / 15 s window |
client.backoffice.timeclock() |
single-digit seconds (1 login + 1 report fetch) | None — BackOffice has no published limit |
The BackOffice method is usually orders of magnitude faster on anything larger than a single day for a single employee. Latency scales roughly with date range because the BackOffice renders the report server-side, but even a full month is a single HTTP round trip.
Session reuse
The BackOffice requests.Session is cached on the client for the
lifetime of the SonnysClient instance. Subsequent timeclock()
calls on the same client reuse the logged-in session (and
transparently re-authenticate if it expires mid-session). Close the
client — or use it as a context manager — to tear the session down.
Shift-level granularity is the default
Unlike LaborCostResult, which only exposes aggregate totals, the
BackOffice result preserves every individual shift. This is the
finest granularity the report exposes and is what enables per-site,
per-day, and anomaly-detection workflows without a second round trip.