Retrieve employee time-clock entries (punch records), including the break records belonging to each entry.
| API name | labor.timeEntry |
| Version | v1 |
| Method | POST |
| Response format | JSON |
1. Getting started#
1.1 Credentials#
To call this API you need two values, issued by OrderPin:
| Credential | Sent as | Description |
|---|---|---|
| Client ID | X-Client-ID header |
Identifies your account |
| API key | Authorization: Bearer <api-key> header |
Secret key — keep it confidential |
Every request must include both. Requests are automatically scoped to the data of the store associated with your credentials; you cannot read another store's data, and you do not need to specify it.
Getting credentials: email api@orderpin.us with your Store ID — that is all OrderPin needs to issue your Client ID and API key. Credentials are issued per store: if your company has several stores, request a separate set for each Store ID.
1.2 Endpoint#
There are two equivalent ways to call the API.
Path form (recommended) — the API name is part of the URL:
POST https://{base-url}/orderpin/oapi/labor/timeEntry/v1
The version segment is optional — POST https://{base-url}/orderpin/oapi/labor/timeEntry uses the
current default version (v1). Pinning the version explicitly is recommended so that your
integration is not affected by future default-version changes.
Generic form — call the base endpoint and declare the API in the request body:
POST https://{base-url}/orderpin/oapi
{
"subject": "labor.timeEntry",
"version": "v1",
"filter": { "modifiedStartAt": 1767225600000, "modifiedEndAt": 1769817600000 }
}
With this form, subject is required (an unknown or missing subject/version is reported with
code 1020); version is optional and defaults to v1. All other parameters (§3) are the same.
The path form is preferred — it keeps the API name in your logs and lets you pin the version in one
place.
1.3 Request headers#
| Header | Required | Value |
|---|---|---|
X-Client-ID |
Yes | Your client ID |
Authorization |
Yes | Bearer <your-api-key> |
Content-Type |
Yes | application/json |
A JSON request body is always required, even when you have no filters to send — use
{}. A request with an empty body is rejected.
2. Request format#
{
"filter": {
"<parameter>": "<value or array of values>"
},
"skip": 0,
"limit": 100,
"sort": [
{ "field": "<field name>", "type": "desc" }
],
"with_record_count": true
}
Example#
POST /orderpin/oapi/labor/timeEntry/v1 HTTP/1.1
Host: {base-url}
X-Client-ID: US00000001
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"filter": {
"modifiedStartAt": 1767225600000,
"modifiedEndAt": 1769817600000,
"status": ["open", "closed"]
},
"skip": 0,
"limit": 100,
"sort": [{ "field": "inAt", "type": "desc" }],
"with_record_count": true
}
3. Request parameters#
Unrecognized parameters are rejected. Only the parameters listed below are accepted.
3.1 Query parameters#
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
subject |
string | No | — | Only needed if you call the generic endpoint POST /orderpin/oapi; set it to labor.timeEntry. Not required when using the endpoint in §1.2. |
version |
string | No | v1 |
Only used together with subject on the generic endpoint. |
filter |
object | Yes | — | One or more filter parameters (§3.2). At least one filter is required; a request with an empty filter object is rejected. Multiple filters are combined with logical AND. |
skip |
integer | No | 0 |
Number of records to skip. Use together with limit to page through results. |
limit |
integer | No | 200 |
Maximum number of records to return, capped at 200. Values above 200 or below 1 are treated as 200. |
sort |
object or array of objects | No | none | Sort order. Each item is { "field": "<name>", "type": "asc" \| "desc" } — both keys are required. See §5 for sortable fields. |
with_record_count |
boolean | No | false |
When true, the response includes recordCount: the total number of records matching your filter, ignoring skip and limit. |
3.2 Filter parameters#
| Parameter | Type | Accepted form | Description |
|---|---|---|---|
uid |
string | single value or array | Match time entries by their uid. A single value matches exactly; an array matches any of the given values. |
status |
string enum | single value or array | Match by entry status. Accepted values: open, closed, voided. Any other value is rejected. |
createAt |
integer (epoch ms) | single value or array | Match time entries created at exactly this timestamp. Must be greater than 0, and at least 15 minutes in the past (see Data freshness below). |
createStartAt |
integer (epoch ms) | single value | Start of a creation-time range (inclusive). Must be sent together with createEndAt. |
createEndAt |
integer (epoch ms) | single value | End of a creation-time range (exclusive). Must be sent together with createStartAt. |
modifiedAt |
integer (epoch ms) | single value or array | Match time entries last modified at exactly this timestamp. Must be greater than 0, and at least 15 minutes in the past (see Data freshness below). |
modifiedStartAt |
integer (epoch ms) | single value | Start of a last-modified range (inclusive). Must be sent together with modifiedEndAt. |
modifiedEndAt |
integer (epoch ms) | single value | End of a last-modified range (exclusive). Must be sent together with modifiedStartAt. |
All timestamps are Unix epoch time in milliseconds (UTC).
Data freshness#
Reporting data becomes queryable approximately 15 minutes after it is recorded. A timestamp
filter value that is less than 15 minutes (900000 ms) old — or in the future — is rejected with
data from the last 15 minutes is not yet available for query. When polling for new data, end your
query range at now - 15 minutes.
Using date ranges#
createStartAt / createEndAt and modifiedStartAt / modifiedEndAt are paired range filters:
- Both halves must be sent together. Sending only one is rejected with
missing pair filter. - The range is half-open:
start <= value < end. startmust be earlier thanend.- The span must be less than 31 days (
2678400000ms). Wider ranges are rejected. - Each half must also satisfy the data freshness rule above (greater than 0 and at least 15 minutes old).
This makes range filters ideal for incremental synchronization: poll with
modifiedStartAt / modifiedEndAt to pick up records that changed since your last run.
Value types#
Values are type-checked. Sending a number where a string is expected (or vice versa) is rejected —
for example, uid must be a string, and modifiedAt must be a number.
When a filter accepts an array, it behaves as "matches any of these values".
4. Response format#
A successful request returns HTTP 200 with a JSON object:
{
"attendance": [
{
"uid": "P202601010001",
"employeeId": "E10086",
"employeeName": "Alice",
"roleId": "R2001",
"roleName": "Cashier",
"inAt": 1767225600000,
"outAt": 1767254400000,
"status": "closed",
"modifiedAt": 1767254400000,
"createAt": 1767225600000,
"breaks": [
{
"uid": "B900001",
"name": "Lunch",
"breakTypeId": "BR3001",
"expectedDuration": 1800,
"inAt": 1767236400000,
"outAt": 1767238200000,
"paidBreak": 0,
"unpaidBreak": 1800
}
]
}
],
"recordCount": 1234
}
| Key | Type | Description |
|---|---|---|
attendance |
array | The matching time entries. May be empty; treat a missing key as an empty list. |
recordCount |
integer | Total matching records. Only present when with_record_count is true. |
4.1 Time entry fields (attendance[])#
| Field | Type | Description |
|---|---|---|
uid |
string | Unique identifier of the time entry. Use this value with the uid filter. |
employeeId |
string | Identifier of the employee the entry belongs to. Matches uid in the Employee API. |
employeeName |
string | Employee display name. May be empty if no name is recorded. |
roleId |
string | Identifier of the employee's role. |
roleName |
string | Name of the employee's role. May be empty if no role name is recorded. |
inAt |
integer (epoch ms) | Punch-in time. |
outAt |
integer (epoch ms) | Punch-out time. 0 or absent while the entry is still open. |
status |
string | Entry status — see the table below. |
modifiedAt |
integer (epoch ms) | Time the entry was last modified. |
createAt |
integer (epoch ms) | Time the entry was created. Currently returns the same value as inAt. |
breaks |
array | Break records belonging to this entry. Empty when the entry has no breaks. See §4.2. |
status values#
| Value | Meaning |
|---|---|
open |
The employee has punched in but not yet punched out. |
closed |
The entry is complete. |
voided |
The entry was canceled and should not be counted as worked time. |
4.2 Break fields (attendance[].breaks[])#
| Field | Type | Description |
|---|---|---|
uid |
string | Unique identifier of the break record. |
name |
string | Break name, e.g. Lunch. |
breakTypeId |
string | Identifier of the break type / rule. |
expectedDuration |
integer (seconds) | Scheduled length of the break. |
inAt |
integer (epoch ms) | Break start time. |
outAt |
integer (epoch ms) | Break end time. |
paidBreak |
integer (seconds) | Portion of the break counted as paid time. |
unpaidBreak |
integer (seconds) | Portion of the break counted as unpaid time. |
5. Sorting and pagination#
5.1 Sorting#
sort accepts a single object or an array of objects (applied in order):
"sort": [
{ "field": "inAt", "type": "desc" },
{ "field": "uid", "type": "asc" }
]
type must be asc or desc, and both field and type are required.
Sortable fields: uid, inAt, outAt, createAt, modifiedAt.
Sorting is supported only on these five fields. Using any other name is rejected:
- A valid response field that does not support sorting (e.g.
employeeId,roleId,status,employeeName,roleName) →field <name> not sortable - A name that is not a response field (including filter names such as
modifiedStartAt) →unknown sort field <name>
5.2 Pagination#
Results are limited to 200 records per request. To page through a larger result set:
- Send the first request with
"with_record_count": true,"skip": 0,"limit": 200. - Read
recordCountfrom the response to determine the number of pages. - Repeat, increasing
skipbylimiteach time, until all records are retrieved.
Always use a sort when paginating so that the ordering is stable across pages.
6. Errors#
Successful requests return HTTP 200. Errors return the HTTP status that matches the error
category, with a JSON body:
{
"code": 1004,
"msg": "Error: [labor.timeEntry.v1] filter modifiedAt: less than 0 [0]"
}
Read the code field for the precise reason. The HTTP status groups errors by category: 4xx for
request and credential problems, 429 for rate limiting, 5xx for server-side failures.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
401 |
2004 |
Missing X-Client-ID header |
Add the header to every request. |
401 |
2003 |
Missing or malformed Authorization header |
Use the exact form Bearer <api-key>. |
401 |
2001 |
Unknown client ID | Verify the client ID issued to you. |
401 |
2005 |
Invalid API key | Verify the key; email api@orderpin.us to have it reissued if lost. |
403 |
2006 |
Open API is not enabled for your account | Email api@orderpin.us with your Store ID to have the API enabled. |
400 |
1004 |
Invalid request parameter | The msg field names the offending parameter and reason — see §6.1. |
200 |
1020 |
Unknown API name or version | Check the endpoint path, subject and version. Note this case still returns HTTP 200, so always check the body code. |
429 |
2012 |
Rate limit exceeded | Honor the Retry-After header — see §7. |
500 |
1003 |
Data retrieval failed | Transient — retry with backoff. |
500 |
1012 |
Internal error | Transient — retry with backoff; email api@orderpin.us if it persists. |
6.1 Common 1004 validation messages#
| Message | Cause |
|---|---|
unknown request param: <name> |
A parameter outside §3.1 was sent. |
no filter |
filter is empty or missing. At least one filter is required. |
unknown filter key: <name> |
A filter outside §3.2 was sent. |
filter <name>: less than 0 [<value>] |
A timestamp filter is <= 0. |
filter <name>: data from the last 15 minutes is not yet available for query [<value>] |
A timestamp filter is less than 15 minutes old — see Data freshness (§3.2). |
filter <name>: value expected [open \| closed \| voided] |
An invalid status value was sent. |
missing pair filter [<name>] |
A range filter was sent without its counterpart. |
END value less than START |
The range end is not after the range start. |
between range greater than 2678400000 |
The range spans 31 days or more. |
expect string but got: <value> |
A filter value of the wrong type (e.g. a number sent for uid). |
parse sort field error: field <name> not sortable |
sort named a response field that does not support sorting (§5.1). |
parse sort field error: unknown sort field <name> |
sort named something that is not a response field. |
no sort field / sort type expect [asc \| desc] |
A sort item is missing field, or type is not asc/desc. |
7. Rate limits#
Requests are limited per client ID, and the quota is shared across the whole service — running several workers in parallel does not give you extra capacity.
The exact values are configured per environment. Read them from the response headers rather than hard-coding them:
| Header | Returned on | Meaning |
|---|---|---|
X-RateLimit-Limit |
every response | Your quota, i.e. the maximum number of requests per window. |
X-RateLimit-Remaining |
every response | Quota left in the current window. Best-effort — it can lag slightly under concurrent load. |
Retry-After |
429 only |
Whole seconds to wait before retrying. |
When you exceed the quota the response is:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
{ "code": 2012, "msg": "[2012] Error: rate limit exceeded, retry after 37 second(s)" }
The window slides continuously rather than resetting at a fixed boundary, so quota comes back
gradually. Waiting the full Retry-After is always sufficient.
How to stay within the limit#
- Retry on
429usingRetry-After, not a fixed sleep. Add jitter if you run multiple workers. - Watch
X-RateLimit-Remainingand slow down before you hit zero. - Prefer wide queries over many narrow ones. A single request with a date range and
limit: 200costs one unit of quota and returns up to 200 records, so paging through a range is far cheaper than one request per record. - Do not retry
5xxin a tight loop — those attempts also consume quota.
Rate limiting is applied only after your credentials have been verified, so a request that fails authentication does not consume your quota.
8. Recommendations#
- Filter by
modifiedStartAt/modifiedEndAtfor incremental synchronization; it is the most efficient way to retrieve only what changed since your last run. - Keep ranges under 31 days. Split longer periods into multiple requests.
- Always send a
sortwhen paginating so pages do not overlap or skip records. - Request
with_record_countonce (on the first page) rather than on every page. - Handle
openentries. They have nooutAtyet and will change later; re-read them via themodifiedAtrange on subsequent runs. - Treat
voidedentries as excluded from worked-time calculations. - Protect your API key. Send it only over HTTPS, never embed it in client-side code, and email api@orderpin.us immediately if you suspect it has been exposed.
- Retry transient failures (
1003,1012) with exponential backoff, and honorRetry-Afteron429.
9. Full example#
curl -X POST 'https://{base-url}/orderpin/oapi/labor/timeEntry/v1' \
-H 'X-Client-ID: US00000001' \
-H 'Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"modifiedStartAt": 1767225600000,
"modifiedEndAt": 1769817600000
},
"skip": 0,
"limit": 200,
"sort": [{ "field": "inAt", "type": "desc" }],
"with_record_count": true
}'