Agency integration guide
How a staffing agency connects TempClock to its payroll, booking, site and billing systems, step by step, with the exact calls, scopes and webhooks for each.
The weekly cycle
Everything in TempClock turns on the pay week (your pay week start day, Monday by default). A shift belongs to the pay week of its clock-in date. Through the week:
| When | In TempClock | What an integration does |
|---|---|---|
| Before the week | Requirements and bookings are made | Your booking system creates requirements and books workers (recipe 2). |
| During the week | Workers clock at the kiosk, the app or your own terminals | Clocks arrive from other hardware (recipe 4); site boards show who is on site (recipe 3). |
| Each day | Coordinators approve shifts; the client signs off and may query or amend | Listen for timesheet.approved, timesheet.client_rejected, timesheet.queried, timesheet.amended; read /exceptions.php for what needs attention. |
| After the week ends | The week is checked and locked on the Close pay week page, and the Sage file is made | pay_week.locked fires; payroll pulls the week (recipe 1); billing pulls charges (recipe 5). |
| Later | A clock that lands in the locked week (for example an offline kiosk) is kept and flagged | It is paid as an adjustment the next week: adjustments=1 on payroll, late_adjustments_due on the pay week. |
Recipe 1: payroll per pay week, with Sage batches
Key: payroll:read (it includes pay), plus webhooks:write to subscribe. Add charges:read only if the payroll system needs charge totals.
There are two ways to get pay into Sage 50 Payroll, and both give the same figures, because the payroll endpoint pays exactly the shifts the Sage file pays, with the same rates, elements and totals:
- The file: payroll staff lock the week and download the Sage 50 Payroll file in TempClock. The API is then only used to confirm the week is final and to reconcile (steps 1 and 4 below).
- The API: the payroll system pulls the lines itself (all four steps).
- Wait for the lock. Subscribe to
pay_week.locked(andpay_week.unlocked). As a fallback, pollGET /pay-weeks.phponce an hour after the week ends:statusgoesopen→checking→ready_to_lock→locked→exported. Before the lock,exceptions_openandshifts_not_approvedsay what is left. - Pull the lines.
GET /payroll.php?week=<week_start>&limit=200&adjustments=1, paging withoffset. Importpay_elements: each line carries your Sage payment reference (sage_ref), hours, rate, amount, department and cost centre.source: lockedshows the frozen figures were used. - Pay late clocks. Rows in
adjustmentswithstate: dueandadjust_weekequal to this week are owed now. They are not in an earlier week's lines, so nothing is paid twice. - Reconcile. The pay week's
batcheslist every file made for it, withfile_sha256and totals.GET /export-batches.php?id=lists the shifts a file held, recorded when it was made (Sage, Xero and payroll files); each shift also carriesexported_batch_idon/timesheets.php, the latest Sage or Xero file that held it.export.createdfires when a file is made.
Is the pay week final, and what was exported? 200
Key used: a key with payroll:read only (pay figures, no charges).
Request
curl -s "$TC_BASE/pay-weeks.php?week_start=2026-09-25" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": [
{
"week_start": "2026-09-21",
"week_end": "2026-09-27",
"status": "exported",
"ended": true,
"locked": true,
"locked_at": "2026-10-01 07:33:35",
"locked_at_utc": "2026-10-01T06:33:35Z",
"locked_by": null,
"lock_id": 19,
"frozen": false,
"shifts": 107,
"shifts_not_approved": 29,
"open_clock_outs": 0,
"exceptions_open": 0,
"late_adjustments_due": 0,
"late_adjustments_paid": 0,
"late_clocks_in_week": 1,
"batches": [
{
"id": 8,
"kind": "sage",
"format": "sage50",
"client_id": 21,
"period_start": "2026-09-21",
"period_end": "2026-09-27",
"entry_count": 2,
"line_count": 4,
"file_name": "sage50_payroll_2026-09-21.csv",
"file_sha256": "2393f9791223b6c74c664b8ad9549911439b7e9f10057d5c5ae9b9807b36f322",
"reason": null,
"created_at": "2026-10-02 07:33:35",
"created_at_utc": "2026-10-02T06:33:35Z",
"stamps_entries": true,
"total_pay": 241.5
}
]
}
],
"meta": {
"period_from": "2026-09-21",
"period_to": "2026-09-27",
"pay_week_start_day": 1,
"approval_rule": "agency_approved"
}
}Payroll lines for one pay week (payroll:read key: pay figures, no charges) 200
Key used: a key with payroll:read only (pay figures, no charges).
Request
curl -s "$TC_BASE/payroll.php?week=2026-09-25&limit=2" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": [
{
"worker_id": 351,
"employee_id": "AX025",
"worker_name": "Aisha Rahman",
"pay_week_start": "2026-09-21",
"pay_week_end": "2026-09-27",
"shifts": 1,
"paid_minutes": 168,
"paid_hours": 2.8,
"gross_pay": 37.8,
"base_pay_rate": 13.5,
"department": "WH-OPS",
"cost_centre": "WH-OPS",
"elements": {
"regular": {
"minutes": 168,
"hours": 2.8,
"pay": 37.8,
"pay_rate": 13.5
},
"overtime": {
"minutes": 0,
"hours": 0,
"pay": 0,
"pay_rate": null
},
"weekend": {
"minutes": 0,
"hours": 0,
"pay": 0,
"pay_rate": null
},
"bank_holiday": {
"minutes": 0,
"hours": 0,
"pay": 0,
"pay_rate": null
},
"night": {
"minutes": 0,
"hours": 0,
"pay": 0,
"pay_rate": null
}
},
"pay_elements": [
{
"element": "regular",
"label": "Basic",
"sage_ref": "Basic",
"minutes": 168,
"hours": 2.8,
"pay_rate": 13.5,
"pay": 37.8,
"department": "WH-OPS",
"cost_centre": "WH-OPS"
}
],
"source": "live",
"flags": []
},
{
"worker_id": 337,
"employee_id": "AX011",
"worker_name": "Amira Patel",
"pay_week_start": "2026-09-21",
"pay_week_end": "2026-09-27",
"shifts": 5,
"paid_minutes": 2098,
"paid_hours": 34.97,
"gross_pay": 472.05,
"base_pay_rate": 13.5,
"department": "MAINT",
"cost_centre": "MAINT",
"elements": {
"regular": {
"minutes": 2098,
"hours": 34.97,
... (82 more lines)One batch with the shifts it paid 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/export-batches.php?id=8" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"id": 8,
"kind": "sage",
"format": "sage50",
"client_id": 21,
"period_start": "2026-09-21",
"period_end": "2026-09-27",
"entry_count": 2,
"line_count": 4,
"file_name": "sage50_payroll_2026-09-21.csv",
"file_sha256": "2393f9791223b6c74c664b8ad9549911439b7e9f10057d5c5ae9b9807b36f322",
"reason": null,
"created_at": "2026-10-02 07:33:35",
"created_at_utc": "2026-10-02T06:33:35Z",
"stamps_entries": true,
"total_pay": 241.5,
"total_charge": 362.25,
"entry_ids": [
36686,
36688
],
"file_ok": false
}
}Which shifts are paid. Closed, approved by the agency, not rejected or disputed by the client and with no open query, signed off by the client when the client must sign off, with a pay rate, and not already paid as a late adjustment. GET /timesheets.php?payroll_status=on_hold lists the rest with the reason in payroll_excluded_reason, so payroll can chase them before the lock.
Recipe 2: booking sync
Key: shifts:write, availability:read, absences:read, workers:read, locations:read. No pay access.
- Create the requirement the client asked for:
POST /demands.phpwith site, date, times, headcount and role. - Find who is free:
GET /availability.php?available_from=&available_to=&location_id=&job_role=. The answer uses the Shifts board's own rules (approved absence, marked unavailable, do not return, overlapping booking, rest, weekly hours), best matches first, with codes only. - Book:
POST /shifts.phpwithworker_idanddemand_id. A hard block is409 not_eligiblewithblock_codes; the API has no override. - Change or cancel:
PUT /shifts.php?id=(times, break, role, worker, site);POST /shifts.php?id=&action=cancelwithclient_cancelled,worker_cancelled,sick,awolorno_show. The worker is told, as in the console. A worked booking or one in a locked week is refused (reason_codeworkedorweek_locked). - Stay in step with changes made in TempClock: subscribe to
shift.created,shift.updated,shift.cancelledandshift.no_show, and run a nightlyGET /shifts.php?updated_since=as a safety net. Offers taken or declined by workers, and cover, send no shift event yet: theupdated_sinceread catches them.GET /offers.php?demand_id=shows who was offered a requirement and how they answered.
Create a requirement: 3 people for an early shift 201
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/demands.php" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"location_id":37,"shift_date":"2026-10-28","start_time":"06:00","end_time":"14:00","headcount":3,"break_minutes":30,"job_role":"Picker"}'Real reply (local test copy, fake demo data)
HTTP 201
{
"data": {
"id": 3066,
"client_id": 21,
"location_id": 37,
"location_name": "London HQ",
"shift_date": "2026-10-28",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-28T06:00:00Z",
"end_utc": "2026-10-28T14:00:00Z",
"break_minutes": 30,
"job_role": "Picker",
"headcount": 3,
"requested": 3,
"filled": 0,
"open": 3,
"status": "open",
"series_id": null,
"source": "api",
"notes": null,
"cancel_reason": null,
"cancelled_at": null,
"created_at": "2026-10-02 07:33:39",
"created_at_utc": "2026-10-02T06:33:39Z",
"updated_at": "2026-10-02 07:33:39",
"updated_at_utc": "2026-10-02T06:33:39Z"
},
"message": "Requirement created."
}Who is free for a slot at a site, best matches first 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/availability.php?available_from=2026-10-28T07%3A00%3A00%2B01%3A00&available_to=2026-10-28T15%3A00%3A00%2B01%3A00&location_id=37&limit=2" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": [
{
"worker_id": 328,
"name": "Emma Thompson",
"employee_id": "AX002",
"location_id": 37,
"department_id": 41,
"job_role": "Administrator",
"eligible": true,
"available": true,
"availability": [
"06:00-22:00"
],
"role_match": null,
"week_hours": 28,
"max_hours_per_week": 48,
"warning_codes": [],
"block_codes": []
},
{
"worker_id": 340,
"name": "George Taylor",
"employee_id": "AX014",
"location_id": 38,
"department_id": 40,
"job_role": "Warehouse Operative",
"eligible": true,
"available": true,
"availability": [
"06:00-22:00"
],
"role_match": null,
"week_hours": 28.5,
"max_hours_per_week": 48,
"warning_codes": [],
"block_codes": []
}
],
"pagination": {
"total": 10,
"limit": 2,
"offset": 0,
"has_more": true
},
"meta": {
"slot": {
"shift_date": "2026-10-28",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-28T06:00:00Z",
"end_utc": "2026-10-28T14:00:00Z",
"location_id": 37,
"job_role": null
}
}
}Book a worker onto the requirement 201
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/shifts.php" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"worker_id":409,"demand_id":3066,"status":"published"}'Real reply (local test copy, fake demo data)
HTTP 201
{
"data": {
"id": 31188,
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"department_id": 40,
"location_id": 37,
"location_name": "London HQ",
"demand_id": 3066,
"shift_date": "2026-10-28",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-28T06:00:00Z",
"end_utc": "2026-10-28T14:00:00Z",
"break_minutes": 30,
"job_role": "Picker",
"notes": null,
"status": "published",
"cancel_reason": null,
"cancelled_at": null,
"acknowledged_at": null,
"declined_at": null,
"confirmation": "unconfirmed",
"created_at": "2026-10-02 07:33:39",
"created_at_utc": "2026-10-02T06:33:39Z",
"updated_at": "2026-10-02 07:33:39",
"updated_at_utc": "2026-10-02T06:33:39Z",
"warning_codes": [
"role"
]
},
"message": "Shift created."
}Cancel a booking (the client no longer needs it) 200
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/shifts.php?id=31187&action=cancel" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"client_cancelled","cancel_reason":"Client reduced headcount"}'Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"id": 31187,
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"department_id": 40,
"location_id": 37,
"location_name": "London HQ",
"demand_id": null,
"shift_date": "2026-10-27",
"start_time": "09:00:00",
"end_time": "17:00:00",
"start_utc": "2026-10-27T09:00:00Z",
"end_utc": "2026-10-27T17:00:00Z",
"break_minutes": 30,
"job_role": "Warehouse Operative",
"notes": "Bring safety boots",
"status": "client_cancelled",
"cancel_reason": "Client reduced headcount",
"cancelled_at": "2026-10-02 07:33:39",
"acknowledged_at": null,
"declined_at": null,
"confirmation": "n/a",
"created_at": "2026-10-02 07:33:38",
"created_at_utc": "2026-10-02T06:33:38Z",
"updated_at": "2026-10-02 07:33:39",
"updated_at_utc": "2026-10-02T06:33:39Z"
},
"message": "Booking client cancelled."
}Recipe 3: live on-site feed
Key: onsite:read only (no worker records, no pay), one key per board. The key sees every site of the account; filter with location_id for one board.
- Poll
GET /on-site-now.phpevery 30 to 60 seconds (well inside 60 requests a minute).include_workers=falsegives totals only, for a headcount display. - "On site" is the kiosk's own rule: an open shift clocked in within the last 18 hours, so a night shift stays on the list across midnight and a forgotten clock-out from days ago does not.
within_geofenceis 1 inside, 0 outside and null when unknown; unknown counts as on site.- Build the board from
/on-site-now.php, not from theclock_inandclock_outwebhooks. In v1.3 a clock made at the kiosk or in the worker app is sent to the worker's home client, so a pooled worker clocking at your site sends you no event (see Webhooks)./on-site-now.phplists everyone at your sites, pooled workers included.
Who is on site now, by site 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/on-site-now.php?location_id=37" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"as_of": "2026-10-02T06:33:35Z",
"totals": {
"clocked_in": 1,
"on_site": 1,
"off_site": 0,
"geofence_unknown": 1,
"low_gps_confidence": 0
},
"locations": [
{
"location_id": 37,
"location_name": "London HQ",
"clocked_in": 1,
"on_site": 1,
"off_site": 0,
"geofence_unknown": 1,
"workers": [
{
"worker_id": 409,
"name": "Jordan Example",
"employee_id": "DOCS-77E88",
"entry_id": 40785,
"clock_in": "2026-10-02 07:19:35",
"clock_in_utc": "2026-10-02T06:19:35Z",
"minutes_on_shift": 14,
"within_geofence": null,
"distance_meters": null,
"gps_accuracy": null,
"low_gps_confidence": false
}
]
}
],
"unassigned": []
}
}Totals only, with an onsite:read key (a site board) 200
Key used: a key with onsite:read only.
Request
curl -s "$TC_BASE/on-site-now.php?include_workers=false" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"as_of": "2026-10-02T06:33:35Z",
"totals": {
"clocked_in": 1,
"on_site": 1,
"off_site": 0,
"geofence_unknown": 1,
"low_gps_confidence": 0
},
"locations": [
{
"location_id": 37,
"location_name": "London HQ",
"clocked_in": 1,
"on_site": 1,
"off_site": 0,
"geofence_unknown": 1
}
],
"unassigned": []
}
}Recipe 4: clock feed from other hardware
Key: clock:write only, with the rate limit raised for shift changes.
- Name the worker by the badge number printed on their card:
employee_id(orworker_id). - Send
occurred_at, the time of the read. Up to 15 minutes back is accepted; more than 60 seconds back is flaggedapi_backdatedfor review. Send anIdempotency-Keymade from the read, so a retry never clocks twice. - TempClock links the clock to the worker's booking that covers the time (
scheduled_shift_id), applies the site's geofence (a strict site needslatitudeandlongitude: send the terminal's fixed position), and refuses pending and inactive workers, like the kiosk. - Handle the answers:
already_clocked_inandno_open_clock_inare double reads;worker_pending,worker_inactiveandnot_foundare people who should not be clocking.
Throughput: at 60 requests a minute, 450 people take about 7.5 minutes to send; with occurred_at their hours are right either way. Raise the key to 120 a minute (under 4 minutes), or ask TempClock for up to 600. See Rate limits and the code example.
Clock in by employee ID, with the time it really happened 201
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/clock.php" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: docs-clock-77e88" \
-d '{"employee_id":"DOCS-77E88","action":"in","location_id":37,"occurred_at":"2026-10-02T06:19:35Z"}'Real reply (local test copy, fake demo data)
HTTP 201
{
"data": {
"entry_id": 40785,
"worker_id": 409,
"employee_id": "DOCS-77E88",
"worker_name": "Jordan Example",
"action": "clock_in",
"clock_in": "2026-10-02 07:19:35",
"clock_in_utc": "2026-10-02T06:19:35Z",
"location_id": 37,
"scheduled_shift_id": null,
"risk_flags": [
"api_backdated"
],
"backdated": true
},
"message": "Jordan Example clocked in at 07:19"
}Clock in from outside a warning-mode geofence, with a poor GPS reading: accepted and flagged 201
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/clock.php" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"worker_id":409,"action":"in","location_id":39,"latitude":52.5264,"longitude":-1.8998,"gps_accuracy":150}'Real reply (local test copy, fake demo data)
HTTP 201
{
"data": {
"entry_id": 40786,
"worker_id": 409,
"employee_id": "DOCS-77E88",
"worker_name": "Jordan Example",
"action": "clock_in",
"clock_in": "2026-10-02 07:33:36",
"clock_in_utc": "2026-10-02T06:33:36Z",
"location_id": 39,
"scheduled_shift_id": null,
"risk_flags": [
"geofence_outside",
"low_gps"
],
"backdated": false,
"within_geofence": 0,
"distance_meters": 5004,
"low_gps_confidence": true,
"warning": "Clocked in 5004m from Birmingham Office, recorded."
},
"message": "Jordan Example clocked in at 07:33 (Clocked in 5004m from Birmingham Office, recorded.)"
}Recipe 5: invoicing
Key: charges:read and payroll:read (for the pay week status).
GET /charges.php?week_start=&vat=true returns the charge schedule: lines per site, worker, role and pay element, site totals with billing name, PO, Sage account reference, department and nominal code, VAT, and what was held back and why. A locked week returns its frozen lines (frozen: true), so an invoice never changes after the lock. It is the same builder as the Charge schedule page and its Sage and Xero files.
Charge schedule for one pay week, with VAT 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/charges.php?week_start=2026-09-25&vat=true" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"reference": "CS21-20260921",
"client_id": 21,
"from": "2026-09-21",
"to": "2026-09-27",
"frozen": false,
"partly_frozen": false,
"require_client_approval": false,
"currency": "GBP",
"lines": [
{
"site_id": 39,
"site_name": "Birmingham Office",
"worker_id": 351,
"worker_name": "Aisha Rahman",
"employee_id": "AX025",
"role": "Customer Service Agent",
"cost_code": "WH-OPS",
"po": null,
"element": "regular",
"element_label": "Standard",
"hours": 2.8,
"rate": 18.9,
"amount": 52.92,
"shifts": 1,
"first_date": "2026-09-24",
"last_date": "2026-09-24",
"frozen": false,
"no_charge": false
},
{
"site_id": 39,
"site_name": "Birmingham Office",
"worker_id": 337,
"worker_name": "Amira Patel",
"employee_id": "AX011",
"role": "Administrator",
"cost_code": "MAINT",
"po": null,
"element": "regular",
"element_label": "Standard",
"hours": 34.97,
"rate": 18.9,
"amount": 660.87,
"shifts": 5,
"first_date": "2026-09-21",
"last_date": "2026-09-25",
"frozen": false,
"no_charge": false
},
{
"site_id": 39,
"site_name": "Birmingham Office",
"worker_id": 339,
"worker_name": "Charlotte Evans",
"employee_id": "AX013",
"role": "Customer Service Agent",
"cost_code": "WH-OPS",
"po": null,
"element": "regular",
"element_label": "Standard",
"hours": 16.43,
"rate": 18.9,
"amount": 310.6,
"shifts": 3,
"first_date": "2026-09-21",
"last_date": "2026-09-24",
"frozen": false,
"no_charge": false
},
{
"site_id": 39,
"site_name": "Birmingham Office",
"worker_id": 342,
"worker_name": "Hannah Williams",
"employee_id": "AX016",
"role": "Customer Service Agent",
"cost_code": "LOG-DRIV",
"po": null,
... (561 more lines)Pooled workers and several clients
- A worker can be pooled: their home client is one account, and they work at another client's site. A time record, booking or payroll line belongs to the client that owns the site, so the site's client sees and pays it, exactly as in the Sage file.
- A key belongs to one client account and covers all its sites.
- An agency-wide key that covers several client accounts is planned and not available in v1.3. Until then, make one key per client account and run each integration per account. Webhook payloads name the account in
data.client_id, so one receiver can serve several accounts, with one exception in v1.3: clocks from the kiosk and the worker app carry noclient_idand go to the worker's home client. Nothing in their payload or headers names the subscription, so give each account's subscription its own receiver URL (for examplehttps://hooks.example.com/tempclock?account=21) and route on that, or check the signature with each account's signing secret and use the account whose secret verifiesX-TempClock-Signature. For API replies, use the key you called with to know the account.
Go-live checklist
- API access is switched on for the account; one key per system, each with only the scopes it needs (recipes).
- Keys stored in a secret store; an expiry date where the integration is temporary; a rotation date in the calendar.
- Webhook receivers check the signature, answer within 5 seconds and de-duplicate on
X-TempClock-Delivery; a test delivery has reached each one. - Every write sends an
Idempotency-Key; 429 and 5xx are retried with backoff;X-Request-Idis logged. - Sync jobs use
updated_sinceand de-duplicate onid; times are stored from the*_utcfields. - Someone checks Setup > API logs for failures in the first weeks.