Skip to main content

Attendance API

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

POST

your endpoint URL

202 + a Location header

2. Retrieve

GET

the URL from Location

202 while pending, 200 with data when ready

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-09

No 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

202

The result is not ready yet.

Wait, then request the same URL again.

200

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

startDate

string

Start of the date range, as YYYY-MM-DD. This date is included.

endDate

string

End of the date range, as YYYY-MM-DD. This date is included.

senseHrId

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 startDate supplied — the range runs from that date to 30 days later.

  • Only endDate supplied — the range runs from 30 days earlier to that date.

Rejected with a 400:

Condition

Error code

More than 30 days between startDate and endDate

DATE_RANGE_TOO_WIDE

endDate earlier than startDate

DATE_RANGE_INVERTED

A date that is not a real calendar date in YYYY-MM-DD form — 01/09/2026, 2026-3-31, 2026-02-30

DATE_INVALID

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-74be056e87cd

    • repeated 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" done

Submit 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

startDate

string

First day served, YYYY-MM-DD.

endDate

string

Last day served, YYYY-MM-DD.

senseHrIds

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

senseHrId

string

Unique, stable Sense HR identifier for the employee. Matches the senseHrId returned by the Employee Data API and the Calendar Events API.

firstName

string | null

Employee's first name.

lastName

string | null

Employee's last name. null where the employee has only a single recorded name.

date

string | null

The day this record covers, ISO 8601 (UTC) at midnight, e.g. 2026-09-01T00:00:00.000Z.

isWorkingDay

boolean | null

true where the day is a scheduled working day for this employee, false where it is not. null where Sense HR holds no value.

patternStartTime

string | null

Scheduled start time from the employee's working pattern, as HH:MM (24-hour). null on non-working days or where no pattern is held.

patternEndTime

string | null

Scheduled end time from the working pattern, as HH:MM.

earliestClockIn

string | null

The earliest clock-in recorded on the day. null where the employee did not clock in.

latestClockOut

string | null

The latest clock-out recorded on the day. null where the employee did not clock out.

breakDurationInMins

number | null

Total break time actually recorded on the day, in minutes. null where no value is held.

patternBreakDurationInMins

number | null

Break time the employee's working pattern allows for, in minutes. null where no value is held.

events

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

eventId

string | null

Identifier for the event in Sense HR.

eventName

string | null

The name of the event as configured in Sense HR — for example Holiday, Sickness, Working from home. This list is not fixed and reflects your own configuration.

eventType

string | null

The category of event — for example TIME_OFF, SICKNESS, WORKING, PUBLIC_HOLIDAY. Treat this list as open and handle unrecognised values gracefully.

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

code

Stable machine-readable identifier. Branch on this, not on message.

message

Human-readable explanation, safe to log or surface to an operator.

maxDays

The window limit, so a client can adapt without hard-coding 30.

requested

The startDate and endDate as received, echoed back for diagnosis. Long values are truncated.

Codes

Code

Meaning

Fix

DATE_RANGE_TOO_WIDE

More than 30 days between startDate and endDate.

Split into consecutive requests of 30 days or fewer.

DATE_RANGE_INVERTED

endDate is earlier than startDate.

Swap them.

DATE_INVALID

A supplied date is not a real calendar date in YYYY-MM-DD form.

Reformat. Note that 2026-02-30 is well-formed but not a real date, and is rejected.

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.

Did this answer your question?