Webhooks
TempClock tells your systems when something happens: a clock, an approval, a locked pay week, an export, a booking change. Each event is a signed HTTPS POST, sent within a minute, from the consoles, the client portal, the kiosk, the worker app, the API and the scheduled jobs. Each event below lists exactly where it comes from.
Subscribing
Make a subscription with POST /webhooks.php (scope webhooks:write), or ask the agency to add one in the agency console under Setup > Webhooks. Give a public HTTPS address on port 443 and the events you want (or ["*"] for all of them, including events added later). The signing secret is in the reply once: store it with the key.
Subscribe to payroll events (the secret is shown once) 201
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/webhooks.php" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/tempclock","events":["pay_week.locked","export.created","timesheet.approved"],"description":"Payroll system"}'Real reply (local test copy, fake demo data)
HTTP 201
{
"data": {
"secret": "68d11aea9cfc6db302a5cbd128eaa91a0be0d7c187fa592fbbf362fb12272383",
"id": 31,
"url": "https://hooks.example.com/tempclock",
"events": [
"pay_week.locked",
"export.created",
"timesheet.approved"
],
"active": true,
"description": "Payroll system",
"api_key_id": 30,
"created_at": "2026-10-02 07:33:40",
"created_at_utc": "2026-10-02T06:33:40Z",
"updated_at": null,
"updated_at_utc": null
},
"message": "Webhook subscription created. Store the secret securely: it will not be shown again."
}A subscription pushes the same data the read endpoints return, so subscribing to an event also needs that event's read scope (shown in the table below). * needs all four. Without it the answer is 403 insufficient_scope with required_scope and field: events.
A subscription made with an API key follows that key. If the key is revoked, expires or loses the read scope an event needs, or the account's API access is switched off, nothing more is sent to it, and each waiting delivery is recorded as refused, with the reason, in the delivery log. Rotating a key does not move its subscriptions: they stop when the old key's overlap ends, so create them again with the new key before then (each gets a new signing secret). The new key can then pause or delete the old key's subscriptions, so they do not count towards the limit of 25.
A key changes only the subscriptions it made, and those made with a key it replaced by rotation (directly or through earlier rotations). PUT, DELETE, a test delivery and rotate_secret on a subscription made in the agency console (api_key_id is null) or with any other key answer 403 forbidden and change nothing. The key can still read it. Ask the agency to change a console-made subscription, or make your own with the key.
Addresses inside a private network are refused, in any written form (private, loopback, link-local and cloud metadata addresses, IPv4 or IPv6), and each delivery connects only to the address that was checked. At most 25 subscriptions per account.
Events
Generated from the event catalogue the API and the admin screen use. GET /webhooks.php?action=events returns the same list. Use the dot-style names: the older names in the Legacy group fire for API changes only and are kept for existing subscriptions. Where each event comes from lists, for every event, the screens, jobs and endpoints that send it, the changes that do not send it today, and every field of its data.
Clock
| Event | When | Read scope needed | Payload |
|---|---|---|---|
clock_in | A worker clocked in (kiosk, worker app or API). | timesheets:read | Example |
clock_out | A worker clocked out (kiosk, worker app or API). total_minutes is the real time worked. | timesheets:read | Example |
clock.blocked_by_survey | A clock-in was refused because a pre-clock-in survey answer blocks it. | timesheets:read | |
clock.geofence_breach | A clock-in was recorded outside the geofence of a site in warning mode. | timesheets:read | Example |
clock.geofence_refused | A clock was refused outside a site's geofence: at a strict site from the kiosk or the API (also when the clock has no GPS), or at any mapped site from the worker app, which checks the fence whatever the site's mode. A clock-out is checked as well as a clock-in. data.action says which: in or out, or at the kiosk break_start or break_end. | timesheets:read | Example |
clock.low_gps_confidence | A clock-in was recorded with a GPS reading worse than the site threshold. | timesheets:read | Example |
Time records
| Event | When | Read scope needed | Payload |
|---|---|---|---|
time_entry.created | A time record was added by hand in the agency or client console: a manual clock-in, or a whole shift from a paper backfill. data.change says which (manual_clock_in or backfill). | timesheets:read | Example |
time_entry.updated | A time record was corrected, clocked out by hand or deleted. data.change says which. | timesheets:read | Example |
time_entry.auto_closed | An open shift was closed automatically: by the site's auto clock-out rule, or by the kiosk when the worker clocked in for the next shift (change missed_clockout). Check and correct it. | timesheets:read | Example |
Approval
| Event | When | Read scope needed | Payload |
|---|---|---|---|
timesheet.approved | One or more shifts were approved in the agency console or the client console. data.via says where; data.entries lists them. | timesheets:read | Example |
timesheet.unapproved | Approved shifts are no longer approved: the approval was reverted, the shift was corrected after approval, or the client disputed it in the client console. data.entries[].approval_status says where each one is now. | timesheets:read | Example |
timesheet.client_approved | The client approved one or more shifts in the portal. | timesheets:read | Example |
timesheet.client_rejected | The client rejected one or more shifts. data.reason says why. | timesheets:read | Example |
timesheet.queried | A query was raised about a shift: by the client (portal or client console), or by the agency or the worker. data.via says who. The shift is held until the query is answered. | timesheets:read | Example |
timesheet.amended | A client amendment request was raised, accepted, declined or withdrawn. data.status says which. | timesheets:read | Example |
Payroll
| Event | When | Read scope needed | Payload |
|---|---|---|---|
pay_week.locked | A client's whole pay week was locked and its figures frozen. Safe to pull payroll now. A lock on one site, or on part of a week, sends period.locked only. | payroll:read | Example |
pay_week.unlocked | A locked pay week was unlocked. Figures may change until it is locked again. | payroll:read | Example |
period.locked | A payroll period was locked for the whole client or one site (data.location_id). Shifts in it can no longer be edited. | payroll:read | Example |
period.unlocked | A payroll lock was lifted. Shifts in that period can be edited again. | payroll:read | Example |
export.created | A payroll or invoice export batch was recorded (Sage, Xero or payroll file). An export for all clients sends one event to each client in it. | payroll:read | Example |
Bookings
| Event | When | Read scope needed | Payload |
|---|---|---|---|
shift.created | A shift was booked. | shifts:read | Example |
shift.updated | A booking changed: times, break, role or worker. | shifts:read | Example |
shift.cancelled | A booking was cancelled, the worker was unassigned, or the booking was deleted (data.deleted true). data.status and data.cancel_reason say why. | shifts:read | Example |
shift.no_show | A booked worker did not clock in by the start time plus the grace period, or was marked no-show. | shifts:read | Example |
Workers
| Event | When | Read scope needed | Payload |
|---|---|---|---|
worker.created | A worker was added (console or API). A worker who registered themselves is announced when they are approved in the agency console or the client console, one at a time or in bulk. | workers:read | Example |
worker.updated | A worker's record changed. data.changes lists the fields. A worker moved to another client is announced to both clients; the old client's copy has data.moved_out true. | workers:read | Example |
document_expired | A worker's compliance document reached its expiry date. | workers:read | Example |
Legacy
| Event | When | Read scope needed | Payload |
|---|---|---|---|
worker_created | Sent when a worker is created through the API only. Use worker.created. | workers:read | Example |
worker_updated | Sent when a worker is changed through the API only. Use worker.updated. | workers:read | Example |
shift_created | Sent when a shift is booked through the API only. Use shift.created. | shifts:read | Example |
Test
| Event | When | Read scope needed | Payload |
|---|---|---|---|
ping | A test delivery sent from the API or the admin screen. | none | Example |
Where each event comes from
Approvals, corrections, queries, locks, bookings and worker changes made in the agency console and the client console send the same events as the API and the client portal. The lists below say exactly which screens, jobs and endpoints send each event today, and which changes do not; for those, read the matching endpoint with updated_since. data.via names the screen or step and data.source the channel (api, kiosk, detector, marked). Treat both as labels: new values can be added.
clock_in
A worker clocked in (kiosk, worker app or API).
Sent from
- Kiosk, including clocks a kiosk sends after it was offline (source kiosk_offline).
- Worker app (source kiosk, method mobile).
- API: POST /clock.php (source api).
Not sent for: A clock-in added by hand in either console (that sends time_entry.created).
Fields of data (16)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
clock_in | The clock-in time, UK local. |
clock_in_utc | API clocks only: clock_in in UTC. |
risk_flags | API clocks only: the entry's risk flags, for example api_backdated, locked_week, dormant_worker. |
clock_out
A worker clocked out (kiosk, worker app or API). total_minutes is the real time worked.
Sent from
- Kiosk, including offline clocks.
- Worker app.
- API: POST /clock.php.
Not sent for: A clock-out set by hand in either console (time_entry.updated, change clocked_out) or closed automatically by the auto clock-out rule or at the next shift's clock-in (time_entry.auto_closed).
Fields of data (19)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
clock_in | The clock-in time, UK local. |
clock_in_utc | API clocks only: clock_in in UTC. |
risk_flags | API clocks only: the entry's risk flags, for example api_backdated, locked_week, dormant_worker. |
clock_out | The clock-out time, UK local. |
clock_out_utc | API clocks only: clock_out in UTC. |
total_minutes | Real minutes worked, right across a clock change. |
clock.blocked_by_survey
A clock-in was refused because a pre-clock-in survey answer blocks it.
Sent from
- Kiosk: a pre-clock-in survey answer blocks the clock-in.
- API: a blocking answer in survey_answers on POST /clock.php.
Fields of data (17)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
attempted_at | When the clock was tried, UK local (no UTC twin). |
survey_id | The survey. |
question_id | The question whose answer blocks. |
message | What the worker was told. |
clock.geofence_breach
A clock-in was recorded outside the geofence of a site in warning mode.
Sent from
- Kiosk and API: a clock-in outside the fence of a site in warning mode (the clock is kept and flagged).
Not sent for: The worker app (it refuses a clock outside the fence instead).
Fields of data (17)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
clock_in | The clock-in time, UK local. |
clock_in_utc | API clocks only: clock_in in UTC. |
risk_flags | API clocks only: the entry's risk flags, for example api_backdated, locked_week, dormant_worker. |
location_name | The site's name. |
clock.geofence_refused
A clock was refused outside a site's geofence: at a strict site from the kiosk or the API (also when the clock has no GPS), or at any mapped site from the worker app, which checks the fence whatever the site's mode. A clock-out is checked as well as a clock-in. data.action says which: in or out, or at the kiosk break_start or break_end.
Sent from
- Kiosk: at a strict site, a clock-in, clock-out or break outside the fence or with no GPS. A clock-out or break is checked against the open shift's site.
- API: at a strict site, a clock-in or clock-out outside the fence or with no coordinates.
- Worker app: a clock-in or clock-out outside the fence of any mapped site, whatever the site's geofence mode (the site radius, or the default radius when none is set).
Not sent for: The worker app refusing for no GPS, a weak GPS reading or a site with no map position (refused with no event).
Fields of data (17)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
attempted_at | When the clock was tried, UK local (no UTC twin). |
action | What was refused: in or out, or at the kiosk break_start or break_end. A refused out means the worker is still clocked in. |
location_name | The site's name. |
radius_meters | The fence radius in metres. |
clock.low_gps_confidence
A clock-in was recorded with a GPS reading worse than the site threshold.
Sent from
- Kiosk, worker app and API: a clock-in with a GPS reading worse than the site's threshold (the clock is kept and flagged).
Fields of data (17)
| Field | What it holds |
|---|---|
entry_id | The time entry. 0 on a refusal (nothing was recorded). |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID (badge number). |
location_id | The site. |
scheduled_shift_id | The booking the clock is for, or null. |
method | How the worker was identified: face, pin, mobile (the worker app) or api. |
kiosk_id | The kiosk, or mobile: and a device code from the worker app, or null. |
source | kiosk (the kiosk and the worker app), kiosk_offline (a kiosk clock sent after the kiosk was offline) or api. |
within_geofence | 1 inside the fence, 0 outside, null when not checked. |
distance_meters | Metres from the site, or null with no GPS. |
low_gps_confidence | True when the GPS reading was worse than the site threshold. |
client_id | API clocks only (source api): the client that owns the site. A kiosk or worker app clock goes to the worker's home client and has no client_id. |
clock_in | The clock-in time, UK local. |
clock_in_utc | API clocks only: clock_in in UTC. |
risk_flags | API clocks only: the entry's risk flags, for example api_backdated, locked_week, dormant_worker. |
gps_accuracy | The reading's accuracy in metres. |
time_entry.created
A time record was added by hand in the agency or client console: a manual clock-in, or a whole shift from a paper backfill. data.change says which (manual_clock_in or backfill).
Sent from
- Agency console: Manual clock, clock in.
- Client console: Manual clock, clock in (via tenant).
- Agency or client console: Paper backfill, one event for each whole shift added from a paper sheet (change backfill, via admin or tenant, with batch_id).
Fields of data (30)
| Field | What it holds |
|---|---|
client_id | The client that owns the shift: the site's client, else the worker's home client. |
location_id | The site, or null. |
location_name | The site's name. |
entry_id | The time entry. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
worker_client_id | The worker's home client (differs from client_id for a pooled worker). |
department_id | The worker's department, or null. |
scheduled_shift_id | The booking, or null. |
entry_type | shift or break. |
clock_in | UK local. |
clock_out | UK local, or null while open. |
clock_in_utc | clock_in in UTC. |
clock_out_utc | clock_out in UTC, or null. |
total_minutes | Real elapsed minutes, or null while open. |
manual_break_minutes | A break set by hand, or null. |
pay_adjustment_minutes | Minutes added to or taken off the paid time by hand, or null. |
clock_method | How the clock-in was made (face, pin, manual, api, mobile, backfill). |
clock_out_method | How the shift was closed, or null. |
approval_status | Agency approval: pending, approved or disputed. |
client_approval | Client sign-off: pending, approved or rejected. |
client_rejected_reason | Why the client rejected it, or null. |
queried | True once a query was raised. |
exported_batch_id | The latest Sage or Xero file that held it, or null. |
edit_count | How many times it was corrected. |
change | manual_clock_in or backfill (a whole shift from a paper sheet). |
reason_code | The reason chosen (for a backfill, the one reason given for the sheet). |
via | tenant from the client console. From the agency console: admin on a paper backfill, left out on a manual clock-in. |
batch_id | Paper backfill only: the backfill batch the shift was added in. |
time_entry.updated
A time record was corrected, clocked out by hand or deleted. data.change says which.
Sent from
- Agency and client console: Edit entry (save and delete), inline edit, quick fix, force clock-out, close at the booked end, timesheet override, manual clock-out and delete, bulk delete.
- An approved shift adjustment (adjustment_id), in either console.
- A client amendment request accepted in the agency or client console (via amend_request).
- Payroll setting the clock-out of a shift in a locked week (via locked_week).
Fields of data (31)
| Field | What it holds |
|---|---|
client_id | The client that owns the shift: the site's client, else the worker's home client. |
location_id | The site, or null. |
location_name | The site's name. |
entry_id | The time entry. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
worker_client_id | The worker's home client (differs from client_id for a pooled worker). |
department_id | The worker's department, or null. |
scheduled_shift_id | The booking, or null. |
entry_type | shift or break. |
clock_in | UK local. |
clock_out | UK local, or null while open. |
clock_in_utc | clock_in in UTC. |
clock_out_utc | clock_out in UTC, or null. |
total_minutes | Real elapsed minutes, or null while open. |
manual_break_minutes | A break set by hand, or null. |
pay_adjustment_minutes | Minutes added to or taken off the paid time by hand, or null. |
clock_method | How the clock-in was made (face, pin, manual, api, mobile, backfill). |
clock_out_method | How the shift was closed, or null. |
approval_status | Agency approval: pending, approved or disputed. |
client_approval | Client sign-off: pending, approved or rejected. |
client_rejected_reason | Why the client rejected it, or null. |
queried | True once a query was raised. |
exported_batch_id | The latest Sage or Xero file that held it, or null. |
edit_count | How many times it was corrected. |
change | corrected, clocked_out (an open shift was closed by hand) or deleted (the record as it was before it was deleted). |
via | The screen or step, for example admin_edit_entry, tenant_edit_entry, inline_edit, set_clockout_now and set_clockout_at (quick fix), force_clockout_all_now, force_clockout_booked_end, close_at_scheduled_end, tenant_override, manual_clock_out, manual_clock_delete, bulk_delete, shift_adjustment, amend_request, locked_week. Treat it as a label: new values can be added. |
reason_code | The reason chosen for a console correction or delete. |
changes | The fields that changed (not on a delete). |
adjustment_id | The shift adjustment, when an approved adjustment made the change. |
time_entry.auto_closed
An open shift was closed automatically: by the site's auto clock-out rule, or by the kiosk when the worker clocked in for the next shift (change missed_clockout). Check and correct it.
Sent from
- The auto clock-out job: an open shift closed by the site's auto clock-out rule (change auto_closed).
- A kiosk or worker app clock-in for the worker's next booked shift that closes a shift left open after an earlier booking, as a missed clock-out at the booked end (change missed_clockout).
Fields of data (27)
| Field | What it holds |
|---|---|
client_id | The client that owns the shift: the site's client, else the worker's home client. |
location_id | The site, or null. |
location_name | The site's name. |
entry_id | The time entry. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
worker_client_id | The worker's home client (differs from client_id for a pooled worker). |
department_id | The worker's department, or null. |
scheduled_shift_id | The booking, or null. |
entry_type | shift or break. |
clock_in | UK local. |
clock_out | UK local, or null while open. |
clock_in_utc | clock_in in UTC. |
clock_out_utc | clock_out in UTC, or null. |
total_minutes | Real elapsed minutes, or null while open. |
manual_break_minutes | A break set by hand, or null. |
pay_adjustment_minutes | Minutes added to or taken off the paid time by hand, or null. |
clock_method | How the clock-in was made (face, pin, manual, api, mobile, backfill). |
clock_out_method | How the shift was closed, or null. |
approval_status | Agency approval: pending, approved or disputed. |
client_approval | Client sign-off: pending, approved or rejected. |
client_rejected_reason | Why the client rejected it, or null. |
queried | True once a query was raised. |
exported_batch_id | The latest Sage or Xero file that held it, or null. |
edit_count | How many times it was corrected. |
change | auto_closed, or missed_clockout when the kiosk closed it at the next shift's clock-in. |
timesheet.approved
One or more shifts were approved in the agency console or the client console. data.via says where; data.entries lists them.
Sent from
- Agency console: Timesheets, approve or bulk approve (via admin_timesheets), and Batch edit, approve (via admin_batch_edit).
- Client console: Timesheets, approve or bulk approve (via tenant_timesheets), and Batch edit, approve (via tenant_batch_edit).
- Close pay week: approving the rest of the week (via close_week, with week_start).
Not sent for: Client sign-off in the portal (that is timesheet.client_approved).
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
via | admin_timesheets, admin_batch_edit, tenant_timesheets, tenant_batch_edit or close_week. |
week_start | close_week only: the pay week. |
timesheet.unapproved
Approved shifts are no longer approved: the approval was reverted, the shift was corrected after approval, or the client disputed it in the client console. data.entries[].approval_status says where each one is now.
Sent from
- Agency or client console: revert to pending (via revert_to_pending).
- A correction to an approved shift, in either console (via the correction's screen).
- Client console: disputing an approved shift (via tenant_timesheets).
- A client amendment accepted on an approved shift (via amend_request).
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
via | Where it happened (see above). |
reason_code | A revert or correction: the reason chosen. |
timesheet.client_approved
The client approved one or more shifts in the portal.
Sent from
- Client portal: approve (via portal).
Fields of data (6)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
via | portal. |
timesheet.client_rejected
The client rejected one or more shifts. data.reason says why.
Sent from
- Client portal: reject (via portal).
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
via | portal. |
reason | Why the client rejected the shifts. |
timesheet.queried
A query was raised about a shift: by the client (portal or client console), or by the agency or the worker. data.via says who. The shift is held until the query is answered.
Sent from
- Client portal: query a shift (via portal).
- Client console: dispute on Timesheets (via tenant_timesheets) or raise a query (via tenant).
- Agency console: raise a query (via admin).
- Worker: disputing the hours at clock-out (via worker).
Fields of data (9)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
via | portal, tenant_timesheets, tenant, admin or worker. |
query_id | The query. |
query_reason | The reason code (for example incorrect_hours). Not from the portal. |
reason | What the person wrote, up to 1,000 characters. |
timesheet.amended
A client amendment request was raised, accepted, declined or withdrawn. data.status says which.
Sent from
- Client portal: an amendment request raised (status pending) or withdrawn (withdrawn), via portal.
- Agency or client console (Amend requests): the request accepted or declined.
Fields of data (15)
| Field | What it holds |
|---|---|
client_id | The client that owns the shifts. |
location_id | The site of the shifts in this event (one event per site), or null. |
count | Shifts in this event (at most 100; a bigger action sends several events). |
entry_ids | Their time entry ids. |
entries | Each shift as a time record: the same fields as time_entry.updated without change, via and changes (entry_id, worker, times, approval_status, client_approval, queried and so on). |
status | pending, accepted, declined or withdrawn. |
amendment_id | The request (GET /amendments.php?id=). |
via | portal (raised or withdrawn). |
proposed_clock_in | Raised: the clock-in asked for, UK local (no UTC twin), or null. |
proposed_clock_out | Raised: the clock-out asked for, UK local, or null. |
proposed_break_minutes | Raised: the break asked for, or null. |
reason | Raised: the client's reason. |
client_approval_reset | Raised: true when the client's sign-off was cleared. |
result | Accepted: applied (the shift was changed), or proposed (the change waits for a second person to approve it). |
note | Accepted or declined: the note left by whoever decided, in the agency or client console. |
pay_week.locked
A client's whole pay week was locked and its figures frozen. Safe to pull payroll now. A lock on one site, or on part of a week, sends period.locked only.
Sent from
- Agency or client console: Close pay week, or a lock on the whole client on Financial locks, that covers whole pay weeks.
Not sent for: A lock on one site or on part of a week (period.locked only).
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client. |
location_id | Always null (a pay week is client-wide). |
week_start | The first day of the pay week. |
week_end | The last day. |
lock_id | The payroll lock. |
snapshot_id | The frozen figures. |
shifts | Shifts frozen. |
pay_week.unlocked
A locked pay week was unlocked. Figures may change until it is locked again.
Sent from
- Agency or client console: a lock that froze the week is lifted (unless another lock still covers it).
Fields of data (5)
| Field | What it holds |
|---|---|
client_id | The client. |
location_id | Always null (a pay week is client-wide). |
week_start | The first day of the pay week. |
week_end | The last day. |
lock_id | The payroll lock. |
period.locked
A payroll period was locked for the whole client or one site (data.location_id). Shifts in it can no longer be edited.
Sent from
- Agency or client console: Close pay week, or Financial locks, for the whole client or one site.
Fields of data (9)
| Field | What it holds |
|---|---|
client_id | The client. |
location_id | The site for a site lock, or null for a lock on the whole client. |
location_name | The site's name, or null. |
lock_id | The payroll lock. |
scope | client (every site) or site (location_id only). |
period_start | The first day locked. |
period_end | The last day locked. |
reason | The reason given for the lock. |
forced | True when it was locked with problems still open. |
period.unlocked
A payroll lock was lifted. Shifts in that period can be edited again.
Sent from
- Agency or client console: Financial locks, a lock is lifted.
Fields of data (10)
| Field | What it holds |
|---|---|
client_id | The client. |
location_id | The site for a site lock, or null for a lock on the whole client. |
location_name | The site's name, or null. |
lock_id | The payroll lock. |
scope | client (every site) or site (location_id only). |
period_start | The first day locked. |
period_end | The last day locked. |
reason | The reason given for the lock. |
forced | True when it was locked with problems still open. |
unlock_reason | The reason given for lifting it. |
export.created
A payroll or invoice export batch was recorded (Sage, Xero or payroll file). An export for all clients sends one event to each client in it.
Sent from
- Agency console: a Sage, Xero or payroll file is made.
- Client console: a Sage file (Sage export) or a payroll file (Payroll, Export CSV) is made.
Fields of data (13)
| Field | What it holds |
|---|---|
client_id | The client. |
location_id | Always null. |
batch_id | The batch. For batch_scope client, GET /export-batches.php?id= returns it with its shifts. An all_clients batch is not served by the API (404 not_found) because it holds other clients' figures; read the shifts with GET /timesheets.php instead. |
batch_scope | client, or all_clients for one file made for every client (each client in it gets its own event). |
kind | sage, xero or payroll. |
format | For example sage50, or null. |
period_start | The first day covered. |
period_end | The last day covered. |
entry_count | This client's shifts in the file. |
line_count | Lines in the file, or null for an all-clients file. |
file_sha256 | SHA-256 of the stored file, or null. |
created_at | UK local. |
created_at_utc | created_at in UTC. |
shift.created
A shift was booked.
Sent from
- Agency and client console Shifts board: create and bulk create (via admin or tenant), assign a worker to a requirement (via assign or assign_all).
- Agency and client console Shifts board: copy a week, a day or a requirement, one event for each booking copied (via copy; copies are drafts).
- Shift Patterns, generate shifts from a pattern, in the agency or client console (via pattern; generated shifts are drafts).
- Client console Shift Patterns screen: add a booking (via tenant).
- AI suggestions applied on the Shifts board, in either console: each booking they make, including a copied period (via ai_planner).
- Site board: add a booking (via siteboard; it starts as a draft).
- API: POST /shifts.php (source api).
Not sent for: A worker taking an offer (the first YES by text, offer link or worker app); Cover on the Shifts board and a cover offer taken (the cover booking). Read GET /shifts.php?updated_since= to catch those.
Fields of data (20)
| Field | What it holds |
|---|---|
client_id | The client that owns the booking: the requirement's client, else the site's, else the worker's. |
location_id | The site. |
location_name | The site's name. Not on API changes. |
shift_id | The booking. |
demand_id | The requirement it fills, or null. |
worker_id | The worker, or null for an open place. |
worker_name | Their name. Not on API changes. |
employee_id | Their employee ID. |
shift_date | The date. |
start_time | UK local. |
end_time | UK local. Before start_time = an overnight shift. |
start_utc | The start in UTC. |
end_utc | The end in UTC. |
break_minutes | The booked break. |
job_role | The role, or null. |
status | The booking status after the change (for example draft, published, cancelled, no_show). |
cancel_reason | Why it was cancelled, or null. |
cancelled_at | When it was cancelled, UK local (no UTC twin), or null. Not on API changes. |
via | Console changes: where it was made (admin, tenant, assign, assign_all, unassign, swap, publish, copy, pattern, ai_planner, siteboard, release). Treat it as a label: new values can be added. |
source | API changes: api. |
shift.updated
A booking changed: times, break, role or worker.
Sent from
- Shifts board: change times, break, role, site or worker (via admin or tenant), swap two workers (via swap), publish drafts (via publish).
- Shifts board: editing a requirement's date, times, break or role, which rewrites its bookings: one event for each booking changed (via admin or tenant). Deleting a requirement in the agency console keeps its bookings and takes them off it: one event each (changes demand_id).
- Client console Shift Patterns screen: edit a booking (via tenant) or publish drafts (via publish).
- AI suggestions applied on the Shifts board: a booking retimed, or two workers swapped (via ai_planner).
- Site board: a booking moved or edited (via siteboard).
- API: PUT /shifts.php (source api).
Not sent for: Confirm on the Shifts board or by the worker (status acknowledged, by text or in the worker app), and clearing a confirmation. Read GET /shifts.php?updated_since= to catch those.
Fields of data (25)
| Field | What it holds |
|---|---|
client_id | The client that owns the booking: the requirement's client, else the site's, else the worker's. |
location_id | The site. |
location_name | The site's name. Not on API changes. |
shift_id | The booking. |
demand_id | The requirement it fills, or null. |
worker_id | The worker, or null for an open place. |
worker_name | Their name. Not on API changes. |
employee_id | Their employee ID. |
shift_date | The date. |
start_time | UK local. |
end_time | UK local. Before start_time = an overnight shift. |
start_utc | The start in UTC. |
end_utc | The end in UTC. |
break_minutes | The booked break. |
job_role | The role, or null. |
status | The booking status after the change (for example draft, published, cancelled, no_show). |
cancel_reason | Why it was cancelled, or null. |
cancelled_at | When it was cancelled, UK local (no UTC twin), or null. Not on API changes. |
via | Console changes: where it was made (admin, tenant, assign, assign_all, unassign, swap, publish, copy, pattern, ai_planner, siteboard, release). Treat it as a label: new values can be added. |
source | API changes: api. |
changes | Console changes: the fields that changed. |
previous_status | Console changes: the status before (publish: draft). |
fields_updated | API changes: the fields that changed. |
previous_worker_id | API changes: the worker before a change of worker, or null. |
detached_demand_id | API changes: the requirement it no longer fills after a move, or null. |
shift.cancelled
A booking was cancelled, the worker was unassigned, or the booking was deleted (data.deleted true). data.status and data.cancel_reason say why.
Sent from
- Shifts board: cancel (status cancelled and the like), unassign the worker (via unassign), delete (data.deleted true), drafts left out when publishing (via publish).
- Shifts board: cancelling a requirement (or it and the later dates in its series), which cancels its bookings: one event for each booking cancelled (via admin or tenant).
- Client console Shift Patterns screen: cancel a booking (status cancelled and the like; via tenant) or delete it (data.deleted true).
- AI suggestions applied on the Shifts board: a booking cancelled or its worker unassigned (via ai_planner, status cancelled).
- Workers page of either console: Make dormant or Deactivate (leaver) with "Cancel them and reopen the places" chosen for the workers' future bookings: one event for each booking cancelled (via release, status cancelled).
- Site board: a booking cancelled, or a draft or cancelled booking deleted (data.deleted true), via siteboard.
- API: POST /shifts.php?id=&action=cancel, including sickness and no-shows recorded through the API (data.status says which).
Not sent for: A worker declining a booking (by text, in the worker app, or Decline recorded on the Shifts board; status worker_cancelled); the absent worker's booking stood down when someone covers it (Cover on the Shifts board or a cover offer taken; status no_show, awol, sick or worker_cancelled, and a no_show or awol booking is then announced by shift.no_show). Read GET /shifts.php?updated_since= for cancellations; a deleted booking no longer appears there.
Fields of data (23)
| Field | What it holds |
|---|---|
client_id | The client that owns the booking: the requirement's client, else the site's, else the worker's. |
location_id | The site. |
location_name | The site's name. Not on API changes. |
shift_id | The booking. |
demand_id | The requirement it fills, or null. |
worker_id | The worker, or null for an open place. |
worker_name | Their name. Not on API changes. |
employee_id | Their employee ID. |
shift_date | The date. |
start_time | UK local. |
end_time | UK local. Before start_time = an overnight shift. |
start_utc | The start in UTC. |
end_utc | The end in UTC. |
break_minutes | The booked break. |
job_role | The role, or null. |
status | The booking status after the change (for example draft, published, cancelled, no_show). |
cancel_reason | Why it was cancelled, or null. |
cancelled_at | When it was cancelled, UK local (no UTC twin), or null. Not on API changes. |
via | Console changes: where it was made (admin, tenant, assign, assign_all, unassign, swap, publish, copy, pattern, ai_planner, siteboard, release). Treat it as a label: new values can be added. |
source | API changes: api. |
previous_status | The status before. |
deleted | True when the booking was deleted (the data is the booking as it was). |
changes | Console changes that edit the booking or its requirement: the fields that changed. Not on a delete, an unassign or publish. |
shift.no_show
A booked worker did not clock in by the start time plus the grace period, or was marked no-show.
Sent from
- The no-show job: a booked worker has not clocked in by the start time plus the grace period (source detector).
- The no-show job: a booking marked no-show or AWOL by any route (the Shifts board, the API, Cover or a cover offer taken), once its start has passed and within 24 hours of it (source marked). An API no-show sends shift.cancelled at once and this later.
- Shifts board, or editing a booking on the client console Shift Patterns screen: a booking marked no-show (via admin or tenant). The no-show job then sends it again (source marked), so de-duplicate on shift_id.
Not sent for: The no-show job, for a booking marked no-show or AWOL more than 24 hours after its start. A no-show marked in a console still sends shift.no_show at once; an AWOL, and a no-show recorded through the API, send only shift.cancelled.
Fields of data (25)
| Field | What it holds |
|---|---|
client_id | The client that owns the booking: the requirement's client, else the site's, else the worker's. |
location_id | The site. |
location_name | The site's name. Not on API changes. |
shift_id | The booking. |
demand_id | The requirement it fills, or null. |
worker_id | The worker, or null for an open place. |
worker_name | Their name. Not on API changes. |
employee_id | Their employee ID. |
shift_date | The date. |
start_time | UK local. |
end_time | UK local. Before start_time = an overnight shift. |
start_utc | The start in UTC. |
end_utc | The end in UTC. |
break_minutes | The booked break. |
job_role | The role, or null. |
status | The booking status after the change (for example draft, published, cancelled, no_show). |
cancel_reason | Why it was cancelled, or null. |
cancelled_at | When it was cancelled, UK local (no UTC twin), or null. Not on API changes. |
via | Console changes: where it was made (admin, tenant, assign, assign_all, unassign, swap, publish, copy, pattern, ai_planner, siteboard, release). Treat it as a label: new values can be added. |
source | The no-show job only: detector (it found the no-show) or marked (the booking was already marked no-show or AWOL). |
no_show_event_id | The no-show job only: the no-show record. |
detected_at | The no-show job only: when it was found, UK local. |
detected_at_utc | detected_at in UTC. |
changes | A console change only (not the no-show job): the fields that changed. |
previous_status | A console change only: the status before. |
worker.created
A worker was added (console or API). A worker who registered themselves is announced when they are approved in the agency console or the client console, one at a time or in bulk.
Sent from
- Agency console: Add worker (via admin).
- Client console: Add worker (via tenant).
- A self-registered worker approved on the Workers page of either console, one at a time or in bulk (Approve pending, or Approve all with new badges), with change approved.
- API: POST /workers.php (source api).
Not sent for: Importing workers from a CSV file in either console. Read GET /workers.php?updated_since= to catch those.
Fields of data (17)
| Field | What it holds |
|---|---|
client_id | The worker's client (for a moved worker, see moved_out). |
location_id | Home site, or null. |
worker_id | The worker. |
employee_id | Their employee ID. |
department_id | Department, or null. |
name | Console changes: their name. |
cost_code_id | Console changes: cost code, or null. |
job_role | Console changes: role, or null. |
status | Console changes: active, or pending (registered and not approved). |
active | Console changes: false for a leaver. |
dormant | Console changes: not worked for a while. |
start_date | Console changes: start date, or null. |
end_date | Console changes: end date, or null. |
via | Console changes: admin (agency console) or tenant (client console). |
change | Console changes, when it was a status change: approved (a self-registered worker was approved), deactivated, reactivated, dormant (made dormant) or woken (dormant ended). |
worker_name | API changes: their name. |
source | API changes: api. |
worker.updated
A worker's record changed. data.changes lists the fields. A worker moved to another client is announced to both clients; the old client's copy has data.moved_out true.
Sent from
- Agency console: Edit worker (via admin). A move to another client is announced to both clients.
- Client console: edit a worker, change their site or cost code (via tenant).
- Workers page of either console: deactivate or reactivate, one worker or in bulk, make dormant or wake, and Undo after a deactivation (change deactivated, reactivated, dormant or woken; via admin or tenant).
- Approving a self-registered worker, one at a time or in bulk (change approved).
- API: PUT /workers.php (source api).
Not sent for: Importing workers from a CSV file (changes to existing workers), and a dormant worker taken out of dormant by clocking in. Read GET /workers.php?updated_since= to catch those.
Fields of data (20)
| Field | What it holds |
|---|---|
client_id | The worker's client (for a moved worker, see moved_out). |
location_id | Home site, or null. |
worker_id | The worker. |
employee_id | Their employee ID. |
department_id | Department, or null. |
name | Console changes: their name. |
cost_code_id | Console changes: cost code, or null. |
job_role | Console changes: role, or null. |
status | Console changes: active, or pending (registered and not approved). |
active | Console changes: false for a leaver. |
dormant | Console changes: not worked for a while. |
start_date | Console changes: start date, or null. |
end_date | Console changes: end date, or null. |
via | Console changes: admin (agency console) or tenant (client console). |
change | Console changes, when it was a status change: approved (a self-registered worker was approved), deactivated, reactivated, dormant (made dormant) or woken (dormant ended). |
worker_name | API changes: their name. |
source | API changes: api. |
changes | Console changes: the fields that changed. |
fields_updated | API changes: the fields that changed. |
moved_out | True on the old client's copy when a worker was moved to another client (location_id, department_id and cost_code_id are null on it). |
document_expired
A worker's compliance document reached its expiry date.
Sent from
- The daily alerts job: a worker's compliance document reached its expiry date (once per document and date).
Fields of data (9)
| Field | What it holds |
|---|---|
client_id | The worker's client. |
location_id | Their home site, or null. |
document_id | The document. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
document_type | The kind of document. |
expiry_date | The expiry date. |
is_critical | True for a document the worker cannot work without. |
worker_created
Sent when a worker is created through the API only. Use worker.created.
Sent from
- API only: POST /workers.php. Use worker.created.
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
location_id | Home site, or null. |
department_id | Department, or null. |
source | api. |
worker_updated
Sent when a worker is changed through the API only. Use worker.updated.
Sent from
- API only: PUT /workers.php. Use worker.updated.
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client. |
worker_id | The worker. |
worker_name | Their name. |
employee_id | Their employee ID. |
location_id | Home site, or null. |
fields_updated | The fields that changed. |
source | api. |
shift_created
Sent when a shift is booked through the API only. Use shift.created.
Sent from
- API only: POST /shifts.php. Use shift.created.
Fields of data (7)
| Field | What it holds |
|---|---|
client_id | The client. |
shift_id | The booking. |
worker_id | The worker. |
location_id | The site. |
shift_date | The date. |
start_time | UK local. |
end_time | UK local. |
ping
A test delivery sent from the API or the admin screen.
Sent from
- API: POST /webhooks.php?id=&action=test.
- Agency console: Setup > Webhooks, Send test.
Fields of data (3)
| Field | What it holds |
|---|---|
client_id | The client. |
subscription_id | The subscription tested. |
message | A fixed test message. |
What you receive
Each event is a POST with a JSON body {event, timestamp (UTC), data, delivery_id}. Events are queued and sent by a job that runs every minute, so a slow endpoint never holds up TempClock. Reply with any 2xx within 5 seconds (2 seconds to connect). Anything else is retried after 1 minute, 5 minutes, 30 minutes and 2 hours (5 attempts in all), with one exception: the address is checked again just before each send, and if its host name does not resolve at that moment or resolves to a private address, the delivery fails after that one attempt and is not retried (use Send again once the address works). Deliveries are kept 90 days. Payloads never carry pay or charge figures or a worker's contact details.
| Header | Value |
|---|---|
X-TempClock-Signature | sha256=<hex HMAC-SHA256 of the exact body, keyed with the subscription secret>. Compare in constant time. |
X-TempClock-Event | The event name. |
X-TempClock-Delivery | The delivery id. The same on every retry: use it to ignore repeats. |
X-TempClock-Attempt | 1 to 5. |
User-Agent | TempClock-Webhooks/1.3 |
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-TempClock-Signature: sha256=5d0b3c...
X-TempClock-Event: pay_week.locked
X-TempClock-Delivery: 8841
X-TempClock-Attempt: 1
User-Agent: TempClock-Webhooks/1.3
{
"event": "pay_week.locked",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"week_start": "2025-03-10",
"week_end": "2025-03-16",
"lock_id": 33,
"snapshot_id": 18,
"shifts": 1
},
"delivery_id": 432
}data.client_idis the client that owns the site worked (else the worker's home client), anddata.location_idthe site, so you can route an event without a lookup.- Clocks from the kiosk and the worker app (
clock_in,clock_outand theclock.*events withsourcekioskorkiosk_offline) are different in v1.3. They go to the worker's home client, not the client that owns the site, and theirdatahas noclient_id, noclock_in_utcorclock_out_utc(the times are UK local) and norisk_flags. So when a pooled worker clocks at another client's site, the site's client gets no event. Clocks sent throughPOST /clock.php(source: api) have all of these and go to the site's client. To follow every clock at your sites, readGET /timesheets.php?updated_since=orGET /on-site-now.php. - Where the geofence is checked. The kiosk and the API check the fence only at a strict site. The worker app checks it at every site with a map position, whatever the site's mode, using the site's radius (or the default radius when none is set). A clock-out (or, at the kiosk, a break) is checked against the site of the open shift, so a clock-out made outside the fence is refused and the worker stays clocked in. At a strict site the kiosk and the API also refuse a clock with no GPS.
clock.geofence_refusedis sent for a clock refused outside the fence and, at the kiosk or through the API, for one with no GPS. The worker app sends no event when it refuses a clock for no GPS, a GPS reading that is not accurate enough, or a site with no map position: it always needs a good GPS reading and a mapped site. From every sourcedata.actionsays what was refused:inorout, or at the kioskbreak_startorbreak_end. A refusedoutmeans the worker is still clocked in: follow it up. - Payloads never carry pay or charge figures or a worker's contact details. Fetch those through the API with a key that has the right access.
- Approval events (
timesheet.*) list the shifts indata.entries, up to 100 per event and one event per site; a bigger action sends several events. - Once a worker is anonymised, their name no longer appears in stored or queued payloads.
Verifying the signature
X-TempClock-Signature is sha256= followed by the hex HMAC-SHA256 of the exact raw body, keyed with the subscription secret. Compute it over the bytes you received (before any JSON parsing) and compare in constant time. Reject anything that does not match.
PHP
<?php
$secret = getenv('TEMPCLOCK_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw bytes
$sent = $_SERVER['HTTP_X_TEMPCLOCK_SIGNATURE'] ?? '';
$want = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($want, $sent)) { http_response_code(401); exit; }
$delivery = $_SERVER['HTTP_X_TEMPCLOCK_DELIVERY'] ?? '';
if (already_processed($delivery)) { http_response_code(200); exit; } // a retry: ignore
queue_for_processing(json_decode($body, true), $delivery); // do the work later
http_response_code(200); // answer within 5 secondsNode.js
// Node.js (Express). Keep the raw body: express.raw, not express.json.
const crypto = require('crypto');
app.post('/tempclock', express.raw({ type: 'application/json' }), (req, res) => {
const want = 'sha256=' + crypto.createHmac('sha256', process.env.TEMPCLOCK_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const sent = req.get('X-TempClock-Signature') || '';
const a = Buffer.from(want), b = Buffer.from(sent);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
enqueue(event, req.get('X-TempClock-Delivery')); // de-duplicate on the delivery id
res.sendStatus(200);
});Python
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.post("/tempclock")
def tempclock():
body = request.get_data() # raw bytes
want = "sha256=" + hmac.new(os.environ["TEMPCLOCK_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, request.headers.get("X-TempClock-Signature", "")):
abort(401)
enqueue(request.get_json(), request.headers.get("X-TempClock-Delivery"))
return "", 200Delivery and retries
- Events are queued and sent by a job that runs every minute, so expect them within about a minute.
- Answer with any 2xx within 5 seconds (2 seconds to connect). Do the work after answering: put the event on your own queue.
- Anything else (an error status, a timeout, a refused connection) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours: 5 attempts in all. Redirects are not followed.
- One exception: the address is checked again just before each send, and if its host name does not resolve at that moment (a DNS fault, even a brief one) or resolves to a private address, the delivery is not retried. It is marked failed after that one attempt, with
response_code0 and the reasonBlocked: URL resolves to a disallowed internal/loopback addressin the log. Use Send again in the agency console once the address works, and check the delivery log for such failures. - A retry carries the same
X-TempClock-Deliveryid and a higherX-TempClock-Attempt. Store the ids you have handled and ignore repeats. - Events can arrive out of order (a retry can be overtaken by a newer event). Use
timestamp, and fetch the record from the API when the order matters. - Deliveries are kept for 90 days. Pausing a subscription stops new events being queued; deliveries already queued wait and go out when you resume.
Testing
POST /webhooks.php?id=<id>&action=test sends a ping to that one subscription, now, whatever events it lists, and answers what your endpoint said (response_code, or 0 and an error when it could not be reached). The agency console has the same Send test button. Below, the test copy's endpoint could not be reached, which is what you see when the address is wrong or blocked.
Send a test delivery to this subscription now (here the endpoint could not be reached) 200
Key used: a key holding every scope.
Request
curl -s -X POST "$TC_BASE/webhooks.php?id=31&action=test" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"subscription_id": 31,
"delivery_id": 233,
"delivered": false,
"response_code": 0,
"error": "Blocked: URL resolves to a disallowed internal/loopback address"
},
"subscription_id": 31,
"message": "Test delivery could not reach your endpoint: Blocked: URL resolves to a disallowed internal/loopback address"
}New signing secret: POST /webhooks.php?id=<id>&action=rotate_secret. The new secret is shown once and the old one stops signing at once, so update your receiver first or accept both for a moment.
Delivery log
GET /webhooks.php?id=<id>&action=deliveries pages through the deliveries of a subscription (filter with status=delivered|pending|failed and event=), with the exact payload sent and the first 1 KB of what your endpoint answered. The payload of an event this key could not subscribe to is left out and payload_hidden says why. In the agency console, Setup > Webhooks > Log shows the same, with Send again on a failed delivery (same delivery id).
The delivery log, with payloads 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/webhooks.php?id=31&action=deliveries&limit=5" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": [
{
"id": 233,
"event": "ping",
"status": "failed",
"attempt": 1,
"max_attempts": 5,
"response_code": 0,
"delivered_at": null,
"delivered_at_utc": null,
"next_retry_at": null,
"next_retry_at_utc": null,
"created_at": "2026-10-02 07:33:40",
"created_at_utc": "2026-10-02T06:33:40Z",
"response_body": "Blocked: URL resolves to a disallowed internal/loopback address",
"payload": {
"event": "ping",
"timestamp": "2026-10-02T06:33:40Z",
"data": {
"client_id": 21,
"message": "This is a test delivery from the TempClock API.",
"subscription_id": 31
},
"delivery_id": 233
}
}
],
"pagination": {
"total": 1,
"limit": 5,
"offset": 0,
"has_more": false
}
}One subscription with its latest deliveries 200
Key used: a key holding every scope.
Request
curl -s "$TC_BASE/webhooks.php?id=31" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"id": 31,
"url": "https://hooks.example.com/tempclock",
"events": [
"pay_week.locked",
"export.created",
"timesheet.approved"
],
"active": true,
"description": "Payroll system",
"api_key_id": 30,
"secret": "68d11aea...",
"created_at": "2026-10-02 07:33:40",
"created_at_utc": "2026-10-02T06:33:40Z",
"updated_at": null,
"updated_at_utc": null,
"recent_deliveries": []
}
}Change the events 200
Key used: a key holding every scope.
Request
curl -s -X PUT "$TC_BASE/webhooks.php?id=31" \
-H "Authorization: Bearer $TC_KEY" \
-H "Content-Type: application/json" \
-d '{"events":["pay_week.locked","pay_week.unlocked","export.created","timesheet.approved"]}'Real reply (local test copy, fake demo data)
HTTP 200
{
"data": {
"id": 31,
"url": "https://hooks.example.com/tempclock",
"events": [
"pay_week.locked",
"pay_week.unlocked",
"export.created",
"timesheet.approved"
],
"active": true,
"description": "Payroll system",
"api_key_id": 30,
"secret": "68d11aea...",
"created_at": "2026-10-02 07:33:40",
"created_at_utc": "2026-10-02T06:33:40Z",
"updated_at": "2026-10-02 07:33:40",
"updated_at_utc": "2026-10-02T06:33:40Z"
},
"message": "Webhook subscription updated."
}Delete a subscription 200
Key used: a key holding every scope.
Request
curl -s -X DELETE "$TC_BASE/webhooks.php?id=31" \ -H "Authorization: Bearer $TC_KEY"
Real reply (local test copy, fake demo data)
HTTP 200
{
"message": "Webhook subscription deleted."
}Payload examples
Real queued payloads from a test copy (fake demo data). Where an event can come from TempClock itself (the consoles, the client portal, the kiosk or a scheduled job) and from the API, both are shown, and some events have more than one example (for example a lock on one site, a deleted booking, a refused kiosk clock-out). Today an event sent for a change made through the API carries a shorter data (worker_name instead of name, source: api, and on worker.updated a fields_updated list instead of changes). Read the fields you need defensively, or fetch the record from the API.
clock_in Kiosk
{
"event": "clock_in",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"entry_id": 40880,
"worker_id": 716,
"worker_name": "Kai",
"employee_id": "EMP-4",
"location_id": 95,
"scheduled_shift_id": null,
"method": "face",
"kiosk_id": "gate-1",
"source": "kiosk",
"within_geofence": 1,
"distance_meters": 13,
"low_gps_confidence": false,
"clock_in": "2026-10-02 08:08:58"
},
"delivery_id": 486
}clock_in from the API
{
"event": "clock_in",
"timestamp": "2026-10-02T06:33:35Z",
"data": {
"client_id": 21,
"entry_id": 40785,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"scheduled_shift_id": null,
"method": "api",
"kiosk_id": null,
"source": "api",
"within_geofence": null,
"distance_meters": null,
"low_gps_confidence": false,
"clock_in": "2026-10-02 07:19:35",
"clock_in_utc": "2026-10-02T06:19:35Z",
"risk_flags": [
"api_backdated"
]
},
"delivery_id": 221
}clock_out Kiosk
{
"event": "clock_out",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"entry_id": 40880,
"worker_id": 716,
"worker_name": "Kai",
"employee_id": "EMP-4",
"location_id": 95,
"scheduled_shift_id": null,
"method": "face",
"kiosk_id": "gate-1",
"source": "kiosk",
"within_geofence": 1,
"distance_meters": 13,
"low_gps_confidence": false,
"clock_in": "2026-10-02 04:08:58",
"clock_out": "2026-10-02 08:08:58",
"total_minutes": 240
},
"delivery_id": 488
}clock_out from the API
{
"event": "clock_out",
"timestamp": "2026-10-02T06:33:36Z",
"data": {
"client_id": 21,
"entry_id": 40785,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"scheduled_shift_id": null,
"method": "api",
"kiosk_id": null,
"source": "api",
"within_geofence": null,
"distance_meters": null,
"low_gps_confidence": false,
"clock_in": "2026-10-02 07:19:35",
"clock_out": "2026-10-02 07:33:36",
"clock_in_utc": "2026-10-02T06:19:35Z",
"clock_out_utc": "2026-10-02T06:33:36Z",
"total_minutes": 14,
"risk_flags": [
"api_backdated"
]
},
"delivery_id": 222
}clock.geofence_breach from the API
{
"event": "clock.geofence_breach",
"timestamp": "2026-10-02T06:33:36Z",
"data": {
"client_id": 21,
"entry_id": 40786,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 39,
"scheduled_shift_id": null,
"method": "api",
"kiosk_id": null,
"source": "api",
"within_geofence": 0,
"distance_meters": 5004,
"low_gps_confidence": true,
"clock_in": "2026-10-02 07:33:36",
"clock_in_utc": "2026-10-02T06:33:36Z",
"risk_flags": [
"geofence_outside",
"low_gps"
],
"location_name": "Birmingham Office"
},
"delivery_id": 224
}clock.geofence_refused Kiosk: a clock-out outside a strict fence (the worker stays clocked in)
{
"event": "clock.geofence_refused",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"entry_id": 0,
"worker_id": 716,
"worker_name": "Kai",
"employee_id": "EMP-4",
"location_id": 95,
"scheduled_shift_id": null,
"method": "face",
"kiosk_id": "gate-1",
"source": "kiosk",
"within_geofence": 0,
"distance_meters": 4448,
"low_gps_confidence": false,
"action": "out",
"location_name": "North",
"radius_meters": 300,
"attempted_at": "2026-10-02 08:08:58"
},
"delivery_id": 487
}clock.geofence_refused from the API
{
"event": "clock.geofence_refused",
"timestamp": "2026-10-02T06:33:41Z",
"data": {
"client_id": 21,
"entry_id": 0,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 39,
"scheduled_shift_id": null,
"method": "api",
"kiosk_id": null,
"source": "api",
"within_geofence": null,
"distance_meters": null,
"low_gps_confidence": false,
"action": "in",
"location_name": "Birmingham Office",
"radius_meters": 200,
"attempted_at": "2026-10-02 07:33:41"
},
"delivery_id": 234
}clock.low_gps_confidence from the API
{
"event": "clock.low_gps_confidence",
"timestamp": "2026-10-02T06:33:36Z",
"data": {
"client_id": 21,
"entry_id": 40786,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 39,
"scheduled_shift_id": null,
"method": "api",
"kiosk_id": null,
"source": "api",
"within_geofence": 0,
"distance_meters": 5004,
"low_gps_confidence": true,
"clock_in": "2026-10-02 07:33:36",
"clock_in_utc": "2026-10-02T06:33:36Z",
"risk_flags": [
"geofence_outside",
"low_gps"
],
"gps_accuracy": 150
},
"delivery_id": 225
}time_entry.created from the consoles, portal or a scheduled job
{
"event": "time_entry.created",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": 92,
"location_name": "South",
"entry_id": 40870,
"worker_id": 706,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-10-02 06:08:51",
"clock_out": null,
"clock_in_utc": "2026-10-02T05:08:51Z",
"clock_out_utc": null,
"total_minutes": null,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0,
"change": "manual_clock_in",
"reason_code": "supervisor_instruction"
},
"delivery_id": 450
}time_entry.created Client console: Manual clock, clock in
{
"event": "time_entry.created",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": 96,
"location_name": "South",
"entry_id": 40879,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-10-02 07:08:57",
"clock_out": null,
"clock_in_utc": "2026-10-02T06:08:57Z",
"clock_out_utc": null,
"total_minutes": null,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0,
"change": "manual_clock_in",
"via": "tenant",
"reason_code": "supervisor_instruction"
},
"delivery_id": 479
}time_entry.updated Agency console: Edit entry, a correction
{
"event": "time_entry.updated",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 95,
"location_name": "North",
"entry_id": 40876,
"worker_id": 713,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-26 08:00:00",
"clock_out": "2026-09-26 17:00:00",
"clock_in_utc": "2026-09-26T07:00:00Z",
"clock_out_utc": "2026-09-26T16:00:00Z",
"total_minutes": 540,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 1,
"change": "corrected",
"via": "admin_edit_entry",
"reason_code": "client_request",
"changes": [
"clock_out"
]
},
"delivery_id": 475
}time_entry.updated Agency console: Edit entry, delete (the record as it was)
{
"event": "time_entry.updated",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": 95,
"location_name": "North",
"entry_id": 40878,
"worker_id": 713,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-22 08:00:00",
"clock_out": "2026-09-22 16:00:00",
"clock_in_utc": "2026-09-22T07:00:00Z",
"clock_out_utc": "2026-09-22T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0,
"change": "deleted",
"via": "admin_edit_entry",
"reason_code": "other"
},
"delivery_id": 478
}time_entry.updated via amend_request
{
"event": "time_entry.updated",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40866,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:45:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:45:00Z",
"total_minutes": 525,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 1,
"change": "corrected",
"via": "amend_request",
"changes": [
"clock_out"
]
},
"delivery_id": 426
}time_entry.auto_closed
{
"event": "time_entry.auto_closed",
"timestamp": "2026-10-02T07:08:53Z",
"data": {
"client_id": 68,
"location_id": 92,
"location_name": "South",
"entry_id": 40872,
"worker_id": 709,
"worker_name": "Owen",
"employee_id": "EMP-5",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-10-01 23:08:51",
"clock_out": "2026-10-02 07:08:51",
"clock_in_utc": "2026-10-01T22:08:51Z",
"clock_out_utc": "2026-10-02T06:08:51Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": "auto",
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0,
"change": "auto_closed"
},
"delivery_id": 457
}timesheet.approved Agency console: Timesheets, approve
{
"event": "timesheet.approved",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 95,
"via": "admin_timesheets",
"count": 1,
"entry_ids": [
40874
],
"entries": [
{
"client_id": 71,
"location_id": 95,
"location_name": "North",
"entry_id": 40874,
"worker_id": 713,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-29 08:00:00",
"clock_out": "2026-09-29 16:00:00",
"clock_in_utc": "2026-09-29T07:00:00Z",
"clock_out_utc": "2026-09-29T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "approved",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 471
}timesheet.approved Client console: Timesheets, approve
{
"event": "timesheet.approved",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 96,
"via": "tenant_timesheets",
"count": 1,
"entry_ids": [
40875
],
"entries": [
{
"client_id": 71,
"location_id": 96,
"location_name": "South",
"entry_id": 40875,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:00:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "approved",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 472
}timesheet.approved via admin_batch_edit
{
"event": "timesheet.approved",
"timestamp": "2026-10-02T07:08:51Z",
"data": {
"client_id": 68,
"location_id": 91,
"via": "admin_batch_edit",
"count": 2,
"entry_ids": [
40859,
40861
],
"entries": [
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40859,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-29 08:00:00",
"clock_out": "2026-09-29 16:00:00",
"clock_in_utc": "2026-09-29T07:00:00Z",
"clock_out_utc": "2026-09-29T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "approved",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
},
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40861,
"worker_id": 707,
"worker_name": "Pooled",
"employee_id": "EMP-3",
"worker_client_id": 69,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-29 10:00:00",
"clock_out": "2026-09-29 18:00:00",
"clock_in_utc": "2026-09-29T09:00:00Z",
"clock_out_utc": "2026-09-29T17:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "approved",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 414
}timesheet.unapproved Agency console: revert to pending
{
"event": "timesheet.unapproved",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 96,
"via": "revert_to_pending",
"reason_code": "client_request",
"count": 1,
"entry_ids": [
40877
],
"entries": [
{
"client_id": 71,
"location_id": 96,
"location_name": "South",
"entry_id": 40877,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-23 08:00:00",
"clock_out": "2026-09-23 16:00:00",
"clock_in_utc": "2026-09-23T07:00:00Z",
"clock_out_utc": "2026-09-23T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 477
}timesheet.unapproved Agency console: an approved shift corrected
{
"event": "timesheet.unapproved",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 95,
"via": "admin_edit_entry",
"reason_code": "client_request",
"count": 1,
"entry_ids": [
40876
],
"entries": [
{
"client_id": 71,
"location_id": 95,
"location_name": "North",
"entry_id": 40876,
"worker_id": 713,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-26 08:00:00",
"clock_out": "2026-09-26 17:00:00",
"clock_in_utc": "2026-09-26T07:00:00Z",
"clock_out_utc": "2026-09-26T16:00:00Z",
"total_minutes": 540,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 1
}
]
},
"delivery_id": 476
}timesheet.unapproved Client console: an approved shift disputed
{
"event": "timesheet.unapproved",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 96,
"via": "tenant_timesheets",
"count": 1,
"entry_ids": [
40875
],
"entries": [
{
"client_id": 71,
"location_id": 96,
"location_name": "South",
"entry_id": 40875,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:00:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "disputed",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": true,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 474
}timesheet.unapproved via amend_request
{
"event": "timesheet.unapproved",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": 91,
"via": "amend_request",
"count": 1,
"entry_ids": [
40866
],
"entries": [
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40866,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:45:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:45:00Z",
"total_minutes": 525,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 1
}
]
},
"delivery_id": 427
}timesheet.client_approved
{
"event": "timesheet.client_approved",
"timestamp": "2026-10-02T07:08:51Z",
"data": {
"client_id": 68,
"location_id": 91,
"via": "portal",
"count": 1,
"entry_ids": [
40863
],
"entries": [
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40863,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-30 08:00:00",
"clock_out": "2026-09-30 16:00:00",
"clock_in_utc": "2026-09-30T07:00:00Z",
"clock_out_utc": "2026-09-30T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "approved",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 420
}timesheet.client_rejected
{
"event": "timesheet.client_rejected",
"timestamp": "2026-10-02T07:08:51Z",
"data": {
"client_id": 68,
"location_id": 92,
"via": "portal",
"reason": "Left site at 15:00, not 17:00",
"count": 1,
"entry_ids": [
40864
],
"entries": [
{
"client_id": 68,
"location_id": 92,
"location_name": "South",
"entry_id": 40864,
"worker_id": 706,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-30 08:00:00",
"clock_out": "2026-09-30 16:00:00",
"clock_in_utc": "2026-09-30T07:00:00Z",
"clock_out_utc": "2026-09-30T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "rejected",
"client_rejected_reason": "Left site at 15:00, not 17:00",
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 421
}timesheet.queried from the consoles, portal or a scheduled job
{
"event": "timesheet.queried",
"timestamp": "2026-10-02T07:08:51Z",
"data": {
"client_id": 68,
"location_id": 91,
"via": "portal",
"query_id": 42,
"reason": "Break not taken?",
"count": 1,
"entry_ids": [
40865
],
"entries": [
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40865,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-10-01 08:00:00",
"clock_out": "2026-10-01 16:00:00",
"clock_in_utc": "2026-10-01T07:00:00Z",
"clock_out_utc": "2026-10-01T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "pending",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": true,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 422
}timesheet.queried Client console: dispute on Timesheets
{
"event": "timesheet.queried",
"timestamp": "2026-10-02T07:08:57Z",
"data": {
"client_id": 71,
"location_id": 96,
"via": "tenant_timesheets",
"query_id": 43,
"query_reason": "incorrect_hours",
"reason": "Worker left at 15:00",
"count": 1,
"entry_ids": [
40875
],
"entries": [
{
"client_id": 71,
"location_id": 96,
"location_name": "South",
"entry_id": 40875,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"worker_client_id": 71,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:00:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "disputed",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": true,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 473
}timesheet.amended
{
"event": "timesheet.amended",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": 91,
"status": "pending",
"amendment_id": 19,
"proposed_clock_in": null,
"proposed_clock_out": "2026-09-28 16:30:00",
"proposed_break_minutes": null,
"reason": "Stayed to finish the load",
"client_approval_reset": false,
"via": "portal",
"count": 1,
"entry_ids": [
40866
],
"entries": [
{
"client_id": 68,
"location_id": 91,
"location_name": "North",
"entry_id": 40866,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"worker_client_id": 68,
"department_id": null,
"scheduled_shift_id": null,
"entry_type": "shift",
"clock_in": "2026-09-28 08:00:00",
"clock_out": "2026-09-28 16:00:00",
"clock_in_utc": "2026-09-28T07:00:00Z",
"clock_out_utc": "2026-09-28T15:00:00Z",
"total_minutes": 480,
"manual_break_minutes": null,
"pay_adjustment_minutes": null,
"clock_method": "manual",
"clock_out_method": null,
"approval_status": "approved",
"client_approval": "pending",
"client_rejected_reason": null,
"queried": false,
"exported_batch_id": null,
"edit_count": 0
}
]
},
"delivery_id": 423
}pay_week.locked
{
"event": "pay_week.locked",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"week_start": "2025-03-10",
"week_end": "2025-03-16",
"lock_id": 33,
"snapshot_id": 18,
"shifts": 1
},
"delivery_id": 432
}pay_week.unlocked
{
"event": "pay_week.unlocked",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"week_start": "2025-03-10",
"week_end": "2025-03-16",
"lock_id": 33
},
"delivery_id": 435
}period.locked from the consoles, portal or a scheduled job
{
"event": "period.locked",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"location_name": null,
"lock_id": 33,
"scope": "client",
"period_start": "2025-03-10",
"period_end": "2025-03-16",
"reason": "Week checked and closed for payroll",
"forced": false
},
"delivery_id": 431
}period.locked A lock on one site (scope site)
{
"event": "period.locked",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": 95,
"location_name": "North",
"lock_id": 38,
"scope": "site",
"period_start": "2025-01-13",
"period_end": "2025-01-19",
"reason": "Site closed for the week",
"forced": false
},
"delivery_id": 480
}period.unlocked from the consoles, portal or a scheduled job
{
"event": "period.unlocked",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"location_name": null,
"lock_id": 33,
"scope": "client",
"period_start": "2025-03-10",
"period_end": "2025-03-16",
"reason": "Week checked and closed for payroll",
"forced": false,
"unlock_reason": "Late timesheet correction"
},
"delivery_id": 434
}period.unlocked A lock on one site lifted
{
"event": "period.unlocked",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": 95,
"location_name": "North",
"lock_id": 38,
"scope": "site",
"period_start": "2025-01-13",
"period_end": "2025-01-19",
"reason": "Site closed for the week",
"forced": false,
"unlock_reason": "Opened again for a late correction"
},
"delivery_id": 481
}export.created
{
"event": "export.created",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": null,
"batch_id": 15,
"batch_scope": "client",
"kind": "sage",
"format": "sage50",
"period_start": "2025-03-10",
"period_end": "2025-03-16",
"entry_count": 1,
"line_count": 1,
"file_sha256": "7ea119ad7c42e83264087071a4dfe31cf433be1602fdbad2def0a31276bec0d6",
"created_at": "2026-10-02 08:08:52",
"created_at_utc": "2026-10-02T07:08:52Z"
},
"delivery_id": 433
}shift.created from the consoles, portal or a scheduled job
{
"event": "shift.created",
"timestamp": "2026-10-02T07:08:53Z",
"data": {
"client_id": 68,
"location_id": 91,
"location_name": "North",
"shift_id": 31247,
"demand_id": 3085,
"worker_id": 706,
"worker_name": "Ben",
"employee_id": "EMP-2",
"shift_date": "2026-10-06",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-06T05:00:00Z",
"end_utc": "2026-10-06T13:00:00Z",
"break_minutes": 30,
"job_role": null,
"status": "published",
"cancel_reason": null,
"cancelled_at": null,
"via": "assign"
},
"delivery_id": 460
}shift.created from the API
{
"event": "shift.created",
"timestamp": "2026-10-02T06:33:38Z",
"data": {
"client_id": 21,
"shift_id": 31187,
"worker_id": 409,
"employee_id": "DOCS-77E88",
"location_id": 37,
"demand_id": 3057,
"shift_date": "2026-10-27",
"start_time": "08:00:00",
"end_time": "16:00:00",
"start_utc": "2026-10-27T08:00:00Z",
"end_utc": "2026-10-27T16:00:00Z",
"break_minutes": 30,
"job_role": "Warehouse Operative",
"status": "published",
"source": "api"
},
"delivery_id": 228
}shift.updated from the consoles, portal or a scheduled job
{
"event": "shift.updated",
"timestamp": "2026-10-02T07:08:53Z",
"data": {
"client_id": 68,
"location_id": 91,
"location_name": "North",
"shift_id": 31246,
"demand_id": null,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"shift_date": "2026-10-05",
"start_time": "09:00:00",
"end_time": "16:00:00",
"start_utc": "2026-10-05T08:00:00Z",
"end_utc": "2026-10-05T15:00:00Z",
"break_minutes": 30,
"job_role": null,
"status": "published",
"cancel_reason": null,
"cancelled_at": null,
"changes": [
"start_time"
]
},
"delivery_id": 459
}shift.updated from the API
{
"event": "shift.updated",
"timestamp": "2026-10-02T06:33:39Z",
"data": {
"client_id": 21,
"shift_id": 31187,
"worker_id": 409,
"employee_id": "DOCS-77E88",
"location_id": 37,
"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",
"status": "published",
"source": "api",
"fields_updated": [
"start_time",
"end_time",
"demand_id"
],
"previous_worker_id": null,
"detached_demand_id": 3057
},
"delivery_id": 229
}shift.cancelled from the consoles, portal or a scheduled job
{
"event": "shift.cancelled",
"timestamp": "2026-10-02T07:08:53Z",
"data": {
"client_id": 68,
"location_id": 91,
"location_name": "North",
"shift_id": 31247,
"demand_id": 3085,
"worker_id": 706,
"worker_name": "Ben",
"employee_id": "EMP-2",
"shift_date": "2026-10-06",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-06T05:00:00Z",
"end_utc": "2026-10-06T13:00:00Z",
"break_minutes": 30,
"job_role": null,
"status": "cancelled",
"cancel_reason": "Unassigned",
"cancelled_at": "2026-10-02 08:08:53",
"via": "unassign"
},
"delivery_id": 461
}shift.cancelled from the API
{
"event": "shift.cancelled",
"timestamp": "2026-10-02T06:33:39Z",
"data": {
"client_id": 21,
"shift_id": 31187,
"worker_id": 409,
"employee_id": "DOCS-77E88",
"location_id": 37,
"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",
"status": "client_cancelled",
"source": "api",
"previous_status": "published",
"cancel_reason": "Client reduced headcount"
},
"delivery_id": 230
}shift.cancelled Shifts board: a booking deleted
{
"event": "shift.cancelled",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": 96,
"location_name": "South",
"shift_id": 31250,
"demand_id": 3086,
"worker_id": 714,
"worker_name": "Ben",
"employee_id": "EMP-2",
"shift_date": "2026-10-11",
"start_time": "06:00:00",
"end_time": "14:00:00",
"start_utc": "2026-10-11T05:00:00Z",
"end_utc": "2026-10-11T13:00:00Z",
"break_minutes": 0,
"job_role": null,
"status": "draft",
"cancel_reason": null,
"cancelled_at": null,
"via": "admin",
"deleted": true,
"previous_status": "draft"
},
"delivery_id": 485
}shift.no_show
{
"event": "shift.no_show",
"timestamp": "2026-10-02T07:08:52Z",
"data": {
"client_id": 68,
"location_id": 91,
"location_name": "North",
"shift_id": 31244,
"demand_id": null,
"worker_id": 708,
"worker_name": "Nadia",
"employee_id": "EMP-4",
"shift_date": "2026-10-02",
"start_time": "07:08:00",
"end_time": "15:08:00",
"start_utc": "2026-10-02T06:08:00Z",
"end_utc": "2026-10-02T14:08:00Z",
"break_minutes": 30,
"job_role": null,
"status": "published",
"cancel_reason": null,
"cancelled_at": null,
"no_show_event_id": 2129,
"detected_at": "2026-10-02 08:08:52",
"detected_at_utc": "2026-10-02T07:08:52Z",
"source": "detector"
},
"delivery_id": 455
}worker.created from the consoles, portal or a scheduled job
{
"event": "worker.created",
"timestamp": "2026-10-02T07:08:55Z",
"data": {
"client_id": 68,
"location_id": 91,
"worker_id": 711,
"name": "New",
"employee_id": "EMP-NEW",
"department_id": null,
"cost_code_id": null,
"job_role": null,
"status": "active",
"active": true,
"dormant": false,
"start_date": null,
"end_date": null,
"via": "admin"
},
"delivery_id": 464
}worker.created from the API
{
"event": "worker.created",
"timestamp": "2026-10-02T06:33:35Z",
"data": {
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"department_id": 40,
"source": "api"
},
"delivery_id": 218
}worker.updated from the consoles, portal or a scheduled job
{
"event": "worker.updated",
"timestamp": "2026-10-02T07:08:56Z",
"data": {
"client_id": 68,
"location_id": 92,
"worker_id": 706,
"name": "Ben",
"employee_id": "EMP-2",
"department_id": null,
"cost_code_id": null,
"job_role": "Forklift Driver",
"status": "active",
"active": true,
"dormant": false,
"start_date": null,
"end_date": null,
"via": "tenant",
"changes": [
"job_role"
]
},
"delivery_id": 465
}worker.updated from the API
{
"event": "worker.updated",
"timestamp": "2026-10-02T06:33:35Z",
"data": {
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"fields_updated": [
"job_role",
"cost_code_id"
],
"source": "api"
},
"delivery_id": 220
}worker.updated Agency console: a worker moved to another client (the old client's copy)
{
"event": "worker.updated",
"timestamp": "2026-10-02T07:08:58Z",
"data": {
"client_id": 71,
"location_id": null,
"worker_id": 715,
"name": "Mover",
"employee_id": "EMP-3",
"department_id": null,
"cost_code_id": null,
"job_role": null,
"status": "active",
"active": true,
"dormant": false,
"start_date": null,
"end_date": null,
"moved_out": true,
"via": "admin",
"changes": [
"client_id",
"location_id"
]
},
"delivery_id": 482
}document_expired
{
"event": "document_expired",
"timestamp": "2026-10-02T07:08:53Z",
"data": {
"client_id": 68,
"location_id": 91,
"document_id": 486,
"worker_id": 705,
"worker_name": "Ana",
"employee_id": "EMP-1",
"document_type": "Licence",
"expiry_date": "2026-10-01",
"is_critical": true
},
"delivery_id": 458
}worker_created from the API
{
"event": "worker_created",
"timestamp": "2026-10-02T06:33:35Z",
"data": {
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"department_id": 40,
"source": "api"
},
"delivery_id": 217
}worker_updated from the API
{
"event": "worker_updated",
"timestamp": "2026-10-02T06:33:35Z",
"data": {
"client_id": 21,
"worker_id": 409,
"worker_name": "Jordan Example",
"employee_id": "DOCS-77E88",
"location_id": 37,
"fields_updated": [
"job_role",
"cost_code_id"
],
"source": "api"
},
"delivery_id": 219
}shift_created from the API
{
"event": "shift_created",
"timestamp": "2026-10-02T06:33:38Z",
"data": {
"client_id": 21,
"shift_id": 31187,
"worker_id": 409,
"location_id": 37,
"shift_date": "2026-10-27",
"start_time": "08:00:00",
"end_time": "16:00:00"
},
"delivery_id": 227
}ping from the API
{
"event": "ping",
"timestamp": "2026-10-02T06:33:40Z",
"data": {
"client_id": 21,
"message": "This is a test delivery from the TempClock API.",
"subscription_id": 31
},
"delivery_id": 233
}clock.blocked_by_survey has the same data as the other clock events, sent when a pre-clock-in survey answer blocks a clock-in.
Rules worth knowing
pay_week.lockedis sent only when a whole pay week's figures are frozen: the moment to pull payroll.period.lockedis sent for every lock when it is made, including a lock on one site (scopesiteanddata.location_idset) or on part of a week, andperiod.unlockedwhen it is lifted.- An export for all clients sends
export.createdto each client in it, with that client's shift count. - A worker who registered themselves is announced with
worker.createdwhen they are approved one at a time, in the agency console or the client console. Approving several at once sends nothing yet: readGET /workers.php?updated_since=to catch them. shift.no_showis sent when a booked worker has not clocked in by the start time plus the grace period, or is marked no-show.