Retrieve day-by-day attendance detail from Sense HR — each employee's scheduled pattern alongside their actual clock-in and clock-out times, breaks, and any planner events falling on the day. One call returns every day in a date range, for all employees or a specified subset.
This endpoint is asynchronous: a request is submitted, and the data is collected and retrieved in a second call. The submission is a POST; the retrieval is a GET. See Making a request.
Base URL
https://api.automate.sensewp.com/package/http/endpoint/{your-endpoint-id}The full URL, including your unique endpoint ID, is provided to you when your access is set up. Treat it as sensitive.
Authentication
Every request must include an Authorization header whose value is your API key exactly as issued.
Authorization: your-api-key-here
The key is sent raw — do not prefix it with Bearer. The header name itself is case-insensitive (Authorization and authorization both work), but the key value must match exactly.
If an IP allow-list has been configured for your endpoint, requests must also originate from a registered public IP address. If no allow-list is configured, requests are accepted from any source presenting a valid key.
A request with a missing or incorrect API key, or from an unregistered IP address, returns no data.
Send the same Authorization header on the follow-up request as well as the initial one.
Making a request
Because assembling attendance detail can take longer than a single HTTP request should wait for, this endpoint works in two steps. Each step uses a different method:
Step | Method | URL | Returns |
1. Submit |
| your endpoint URL |
|
2. Retrieve |
| the URL from |
|
The methods are not interchangeable. A GET to the endpoint URL will not submit a request and will not return a Location header, and the retrieval URL accepts GET only.
Step 1 — Submit the request
Send a POST request to your endpoint URL, with parameters supplied in the query string:
POST /{your-endpoint-id}?startDate=2026-08-10&endDate=2026-09-09No request body is required — the parameters travel in the query string, and the POST carries nothing. Send Content-Length: 0, or whatever your client does by default for a body-less POST.
The response is HTTP 202 Accepted. It contains no data. What matters is the Location response header, which holds the path where your result will become available:
http
HTTP/1.1 202 Accepted Location: /package/http/endpoint/requests/TGfviR-lsV0YdRfk2-Z_IH-OBZ3z6U5E_2AWsPBExHujXI-DByCBm3eSDppQXC6D
The path is relative. Prefix it with the API host to get the URL to call:
https://api.automate.sensewp.com/package/http/endpoint/requests/TGfviR-…
Do not attempt to construct this URL yourself, and do not assume the token has any particular length or character set. Always read it from the Location header of the response you just received. Each submission produces a new token.
Note that 202 is not a redirect, so HTTP clients do not follow Location automatically. curl -L, for example, will not follow it — you must issue the second request yourself.
Each POST submits a new request and returns a new token. Submitting is not idempotent, so a client that retries the submission on a network timeout will queue a second identical job. Retry the retrieval, not the submission.
Step 2 — Retrieve the result
Send a GET request to the URL from the Location header. Only GET is accepted here — do not POST to the retrieval URL.
Response | Meaning | What to do |
| The result is not ready yet. | Wait, then request the same URL again. |
| The result is ready. The body contains your data. | Stop polling and process the body. |
There is no separate "status" call and no progress information — a 202 simply means "not yet". Keep requesting the same URL until you receive a 200.
Recommended polling behaviour
Wait 1–2 seconds before your first retrieval attempt.
Then retry every 2–5 seconds.
Apply an overall timeout (60 seconds is a sensible starting point) and treat exceeding it as a failure, rather than polling indefinitely.
Do not poll in a tight loop. Rapid repeated requests do not make the result arrive sooner.
Larger requests take longer to prepare. A single employee over a few days will typically be ready almost immediately; all employees across a full 30-day window takes longer.
Parameters
Parameters are supplied in the query string of the Step 1 POST only. They are not accepted on the retrieval GET, which returns whatever the submission asked for. All three are optional — a bare submission with no parameters returns the last 30 days, ending today.
Parameter | Type | Description |
| string | Start of the date range, as |
| string | End of the date range, as |
| string | Restrict results to specific employees. Omit for all employees. |
startDate and endDate behaviour
Both must be formatted YYYY-MM-DD (for example 2026-03-31). Other formats — including 31/03/2026 and 2026-3-31 — are not accepted, and impossible dates such as 2026-02-30 are rejected.
The range may not exceed 30 days. This is a hard limit: attendance detail returns one record per employee per day, so an unbounded range would return an unworkable volume of data.
A range you supply is either honoured or rejected. It is never silently narrowed — if the endpoint cannot serve what you asked for, it tells you rather than returning a different period that looks correct.
Accepted, no error:
Neither date supplied — the range is the 30 days up to and including today. With nothing to go on, a sensible default is applied.
Only
startDatesupplied — the range runs from that date to 30 days later.Only
endDatesupplied — the range runs from 30 days earlier to that date.
Rejected with a 400:
Condition | Error code |
More than 30 days between |
|
|
|
A date that is not a real calendar date in |
|
Empty parameters (?startDate=&endDate=) count as not supplied, and get the default range rather than an error.
To cover a longer period, issue consecutive requests of 30 days or fewer and combine the results yourself. See Error responses for the response shape, and Confirming the window you got for how to assert the range you were served.
senseHrId behaviour
If omitted, records for all employees are returned.
If supplied, only records for those employees are returned. Three formats are accepted:
a single ID —
?senseHrId=c9d33249-581e-4be9-9a77-74be056e87cdrepeated parameters —
?senseHrId=c9d33249-…&senseHrId=3a50f03d-…a comma-separated list —
?senseHrId=c9d33249-…,3a50f03d-…
IDs that are not in a valid format are ignored. If none of the supplied IDs are valid, the filter is treated as empty and records for all employees are returned.
Use the senseHrId values returned by the Employee Data API to reference employees consistently across all endpoints.
Example requests
Step 1 — submit a request for the default range (last 30 days). Note -X POST, and -i so the response headers are printed:
bash
curl -i -X POST "https://api.automate.sensewp.com/package/http/endpoint/{your-endpoint-id}" \ -H "Authorization: your-api-key-here"http
HTTP/1.1 202 Accepted Location: /package/http/endpoint/requests/TGfviR-lsV0YdRfk2-Z_IH-OBZ3z6U5E_2AWsPBExHujXI-DByCBm3eSDppQXC6D
Step 2 — retrieve the result. This is a GET, which is curl's default, so no -X is needed:
bash
curl -i "https://api.automate.sensewp.com/package/http/endpoint/requests/TGfviR-lsV0YdRfk2-Z_IH-OBZ3z6U5E_2AWsPBExHujXI-DByCBm3eSDppQXC6D" \ -H "Authorization: your-api-key-here"
Repeat until the status is 200 rather than 202.
Submitting for one employee over a specific range:
bash
curl -i -X POST "https://api.automate.sensewp.com/package/http/endpoint/{your-endpoint-id}?startDate=2026-09-01&endDate=2026-09-09&senseHrId=c9d33249-581e-4be9-9a77-74be056e87cd" \ -H "Authorization: your-api-key-here"Be careful not to let curl change the method for you: if you add -d or -F to a request, curl sets POST and moves your data into the body, and if you add -L to a POST, curl may downgrade the followed request to GET. Neither is wanted here — keep the parameters in the URL and don't follow redirects.
Submit and poll — shell
bash
BASE="https://api.automate.sensewp.com" KEY="your-api-key-here" ENDPOINT="$BASE/package/http/endpoint/{your-endpoint-id}" # Step 1: POST to submit, and read the Location header LOCATION=$(curl -sS -X POST -D - -o /dev/null \ "$ENDPOINT?startDate=2026-09-01&endDate=2026-09-09" \ -H "Authorization: $KEY" \ | awk 'BEGIN{IGNORECASE=1} /^location:/ {print $2}' | tr -d '\r') # Step 2: GET the location until it returns 200 for i in $(seq 1 20); do sleep 3 CODE=$(curl -sS -o result.json -w '%{http_code}' \ "$BASE$LOCATION" -H "Authorization: $KEY") [ "$CODE" = "200" ] && echo "Ready" && break echo "Attempt $i: $CODE" doneSubmit and poll — JavaScript
javascript
const BASE = "https://api.automate.sensewp.com"; const KEY = "your-api-key-here"; const ENDPOINT = `${BASE}/package/http/endpoint/{your-endpoint-id}`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function getAttendance(params = {}, { timeoutMs = 60000, intervalMs = 3000 } = {}) { const qs = new URLSearchParams(params).toString(); // Step 1: POST to submit — parameters in the query string, no body const submitted = await fetch(qs ? `${ENDPOINT}?${qs}` : ENDPOINT, { method: "POST", headers: { Authorization: KEY }, redirect: "manual", }); const location = submitted.headers.get("location"); if (!location) throw new Error(`No Location header (status ${submitted.status})`); const resultUrl = new URL(location, BASE).toString(); // Step 2: GET the location until it returns 200 const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(intervalMs); const res = await fetch(resultUrl, { method: "GET", headers: { Authorization: KEY } }); if (res.status === 200) return (await res.json()).data; if (res.status !== 202) throw new Error(`Unexpected status ${res.status}`); } throw new Error("Timed out waiting for the result"); } const days = await getAttendance({ startDate: "2026-09-01", endDate: "2026-09-09" });Response
Once ready, the retrieval request returns HTTP 200 with Content-Type: application/json. The body carries a meta object describing the request that was served, and a data array of day records:
json
{ "meta": { "startDate": "2026-09-01", "endDate": "2026-09-09", "days": 9, "maxDays": 30, "senseHrIds": ["c9d33249-581e-4be9-9a77-74be056e87cd"], "recordCount": 9, "generatedAt": "2026-09-09T16:48:23.525Z" }, "data": [ { "senseHrId": "…", "date": "…", "...": "…" } ] }Field | Type | Description |
| string | First day served, |
| string | Last day served, |
| array | The employee filter that was applied. An empty array means all employees. |
One record represents one employee on one day. A request covering 10 employees over 30 days returns up to 300 records. Non-working days are included — see Non-working days.
If no records fall within the range, data is an empty array. The order of records is not guaranteed.
Day fields
Field | Type | Description |
| string | Unique, stable Sense HR identifier for the employee. Matches the |
| string | null | Employee's first name. |
| string | null | Employee's last name. |
| string | null | The day this record covers, ISO 8601 (UTC) at midnight, e.g. |
| boolean | null |
|
| string | null | Scheduled start time from the employee's working pattern, as |
| string | null | Scheduled end time from the working pattern, as |
| string | null | The earliest clock-in recorded on the day. |
| string | null | The latest clock-out recorded on the day. |
| number | null | Total break time actually recorded on the day, in minutes. |
| number | null | Break time the employee's working pattern allows for, in minutes. |
| array | Planner events falling on this day. Empty array where there are none. See Event fields. |
Fields shown as … | null are null when the underlying value is not held in Sense HR. Empty text values are normalised to null rather than returned as empty strings.
Event fields
Each entry in events describes one planner event falling on that day for that employee.
Field | Type | Description |
| string | null | Identifier for the event in Sense HR. |
| string | null | The name of the event as configured in Sense HR — for example |
| string | null | The category of event — for example |
A day can carry more than one event. A multi-day absence appears on each day it covers, so the same event is repeated across consecutive records — this is the intended behaviour and is what makes this endpoint suitable for day-level reporting.
Unlike the Calendar Events API, public holidays are not filtered out here, because a public holiday is a legitimate explanation for a day with a pattern but no clock-ins.
Example response
json
{ "data": [ { "senseHrId": "c9d33249-581e-4be9-9a77-74be056e87cd", "firstName": "Stew", "lastName": "Welsh", "date": "2026-09-01T00:00:00.000Z", "isWorkingDay": true, "patternStartTime": "09:00", "patternEndTime": "17:00", "earliestClockIn": "08:57", "latestClockOut": "17:04", "breakDurationInMins": 45, "patternBreakDurationInMins": 60, "events": [] }, { "senseHrId": "c9d33249-581e-4be9-9a77-74be056e87cd", "firstName": "Stew", "lastName": "Welsh", "date": "2026-09-02T00:00:00.000Z", "isWorkingDay": true, "patternStartTime": "09:00", "patternEndTime": "17:00", "earliestClockIn": null, "latestClockOut": null, "breakDurationInMins": null, "patternBreakDurationInMins": 60, "events": [ { "eventId": "e6e2f06a-4bd7-4d29-a5b3-355024492f5d", "eventName": "Holiday", "eventType": "TIME_OFF" } ] }, { "senseHrId": "c9d33249-581e-4be9-9a77-74be056e87cd", "firstName": "Stew", "lastName": "Welsh", "date": "2026-09-05T00:00:00.000Z", "isWorkingDay": false, "patternStartTime": null, "patternEndTime": null, "earliestClockIn": null, "latestClockOut": null, "breakDurationInMins": null, "patternBreakDurationInMins": null, "events": [] } ] }Error responses
A request that cannot be served returns HTTP 400 with an error object in place of meta and data:
json
{ "error": { "code": "DATE_RANGE_TOO_WIDE", "message": "Requested range spans 364 days. The maximum is 30 days. Split the period into consecutive requests of 30 days or fewer.", "maxDays": 30, "requested": { "startDate": "2026-01-01", "endDate": "2026-12-31" } } }Field | Description |
| Stable machine-readable identifier. Branch on this, not on |
| Human-readable explanation, safe to log or surface to an operator. |
| The window limit, so a client can adapt without hard-coding |
| The |
Codes
Code | Meaning | Fix |
| More than 30 days between | Split into consecutive requests of 30 days or fewer. |
|
| Swap them. |
| A supplied date is not a real calendar date in | Reformat. Note that |
Validation happens after submission. The POST returns 202 before the parameters have been examined, so a rejection cannot be reported there — it arrives on the retrieval GET. A rejected request therefore looks like any other: 202, then a 400 where you expected a 200. Treat any non-202, non-200 status from the retrieval URL as terminal and stop polling.
Treat the code list as open. New codes may be added, so branch on the codes you handle and fall back to surfacing message for anything unrecognised.
Notes and guidance
Identifying a record
A record is identified uniquely by the combination of senseHrId and date. There is no single-field record identifier. If you are caching records or performing upserts, key on both fields.
eventId inside events identifies the underlying planner event, which may span several days and several employees, so it is not unique within a response.
Confirming the window you got
meta.startDate and meta.endDate are the range that was actually served. Where a default was applied — because you supplied one date or none — they are the only way to know what period your records cover.
If your client cares about the period, assert on meta rather than assuming your parameters took effect:
javascript
if (meta.startDate !== requestedStart || meta.endDate !== requestedEnd) { throw new Error(`Served ${meta.startDate}..${meta.endDate}, expected ${requestedStart}..${requestedEnd}`); }The same applies to the employee filter: meta.senseHrIds is what was applied, and an empty array means every employee was returned. That check is worth having, because an unrecognised senseHrId is still dropped silently — see senseHrId behaviour.
Pattern versus actual
Each record holds two kinds of information, and the distinction matters:
Pattern fields (
patternStartTime,patternEndTime,patternBreakDurationInMins,isWorkingDay) describe what the employee was scheduled to do.Actual fields (
earliestClockIn,latestClockOut,breakDurationInMins) describe what was recorded on the day.
Comparing the two is the point of this endpoint — lateness, early finishes, missed breaks, and unexplained absence all come from the gap between them. But a gap is not by itself evidence of anything: a null clock-in on a scheduled working day may mean the employee was absent, or that they were working somewhere clocking isn't used, or that a device failed. Check events before drawing a conclusion.
Non-working days
Days that are not scheduled working days are included in the response, with isWorkingDay: false and, typically, no pattern or clock times. Weekends and rest days therefore appear as records.
If you only want scheduled days, filter on isWorkingDay === true. Do not infer a non-working day from the absence of a pattern time, since a working day can also lack one.
Working with break durations
breakDurationInMins is what was recorded; patternBreakDurationInMins is what the pattern allows. They are separate values and neither is derived from the other.
A recorded break of 0, or null, does not reliably mean no break was taken — it may mean breaks are not clocked for that employee. Treat break data as indicative unless you know clocking is enforced for the population you are reporting on, and take working time compliance figures from Sense HR directly rather than calculating them here.
Clock times and hours worked
earliestClockIn and latestClockOut are the outer bounds of the day, not a list of every clock event. Where an employee clocks in and out several times, the intervening activity is not represented.
Do not treat latestClockOut minus earliestClockIn as hours worked. That figure includes every break and gap in the day. It is a span, not a total.
Sequential requests for longer periods
Because the range is capped at 30 days, reporting over a quarter or a year means several requests. Each is an independent submit-and-poll cycle. Run them sequentially rather than firing many submissions at once, and note that records are per employee per day, so a year of data for a large workforce is a substantial volume — consider narrowing by senseHrId as well as by date.
Employee monitoring
Clock-in and clock-out times are personal data describing when identifiable individuals started and finished work, and analysing them constitutes monitoring of workers. Anyone holding your API key can retrieve them.
Before consuming this endpoint, satisfy yourself that:
you have a lawful basis for the processing, and that employees have been informed of it;
the purpose you are using the data for is the purpose it was collected for;
your assessment of the monitoring — including a DPIA where one is required — covers this route to the data as well as use inside Sense HR;
access is limited to those who need it, and the data is not exposed in dashboards or reports more widely than intended.
If you would prefer clock times withheld from your endpoint entirely, contact us and we can arrange that.
Troubleshooting
I got a 202 and no data. That is expected on the submission. Read the Location response header and issue a GET to that path, prefixed with the API host. See Making a request.
My submission returned no Location header. Check the method. Submitting requires POST — a GET to the endpoint URL does not submit a request and returns no Location. In curl, that means -X POST.
My retrieval failed, or returned nothing. Check the method here too. The retrieval URL accepts GET only. A POST to it will not return your data. Note that some HTTP clients preserve the method across a whole session or reuse a configured default — if your submission is a POST, make sure the retrieval isn't one as well.
I keep getting 202 from the retrieval URL. The result is not ready yet. Continue polling every few seconds. If it has not resolved within a minute or so, check that you are calling the URL exactly as returned in Location — a truncated or re-encoded token will not resolve — and that you are sending the Authorization header.
My client followed the Location header automatically and got nothing. 202 is not a redirect status, so it should not be followed automatically. Some clients and gateways treat any Location header as a redirect. Disable redirect following and handle the second request explicitly.
My retrieval returned a 400 with DATE_RANGE_TOO_WIDE. The range exceeded 30 days. Split the period into consecutive requests of 30 days or fewer and combine the results. The maxDays field in the error carries the current limit.
My retrieval returned a 400 with DATE_INVALID. A date was not a real calendar date in YYYY-MM-DD form. The requested object in the error echoes what was received, which usually makes the problem obvious — 01/09/2026 and 2026-02-30 are both rejected. Note this is a change from earlier behaviour: malformed dates used to fall back to a default range silently.
I get fewer records than expected, but no error. Check meta.startDate and meta.endDate. If you supplied only one date, or none, a default window was applied and meta tells you which. Also check meta.senseHrIds — an empty array means no employee filter was applied.
I asked for one employee but got everyone. The senseHrId value was not in a valid format, so the filter was discarded. Check the ID against the value returned by the Employee Data API.
Clock times are null on a day the employee worked. Only recorded clocking appears here. If the employee does not clock, or clocked on a system not connected to Sense HR, the fields are null even though the day was worked. Check events and isWorkingDay for context before treating it as absence.
Weekends are in my results. Non-working days are returned by design. Filter on isWorkingDay.
No response, or an error rather than a 202. Confirm you are sending POST, and that the Authorization header is present and contains the key exactly as issued, with no Bearer prefix. If an IP allow-list is configured, confirm your request originates from a registered address.
Related endpoints
Employee Data API — employee records including names, roles, departments, and line managers.
Calendar Events API — planner events as events rather than as days: one record per event, with approval status, duration, and sickness reason. Use it when you need the event itself; use the Attendance API when you need the day.
senseHrId is shared across all three endpoints, so records can be joined directly.