Data sync
Copy your group's Hopshift data into your own database every night with a read-only data sync key
The data sync API is a read-only copy feed. It lets you keep your own database in step with your Hopshift group: employees and their history, structure, attendance, leave, payroll, payslips, government filings and the audit trail. It never changes anything in Hopshift.
Every resource works the same way. You read the rows that changed since your last pass, you upsert them by id, and from time to time you compare ids to remove rows that Hopshift deleted. The resource reference lists every resource and field.
What a key covers
A data sync key belongs to the group, not to a person. It keeps working when the person who created it leaves or loses access, until it expires or someone revokes it. The API keys page shows who created each key and flags keys whose creator can no longer manage API keys.
- Created by the group owner or a whole-group Admin: the key covers the whole group, including companies added later.
- Created by a company-scoped Admin: the key covers the companies the group has at the moment it is created. A company added later needs a new key from the owner or a whole-group Admin.
The Company Access column on the API keys page shows which applies to each key. One key covers every resource; there is no separate permission for payroll or files.
Create a data sync key
You need Group Owner access, or Admin access covering every current company in the group. HR Managers cannot create keys.
- Open Group Settings → API Keys.
- Click Create API Key.
- Enter a Key Name, for example
Nightly sync - Production. - Under Key type, choose Data sync (read-only).
- Choose the expiry date. It is required and must be between today and the same date next year (29 February falls back to 28 February). The key works until the end of that day in UTC.
- Click Create Key.
The key is shown only once. Copy it into a secrets manager before you close the dialog. A data sync key can read national ID, tax, bank and pay details for every employee it covers, so treat it like a password: never commit it, never put it in a URL and never share it in chat.
Use the key
Send the key as a Bearer token on every request:
Authorization: Bearer hops_<64 hexadecimal characters>Keys start with hops_ followed by 64 hexadecimal characters. A missing header, any other format, an unknown key, a revoked key or an expired key gets 401 Unauthorized. An Employee list key (scope employees:read) gets 403 Forbidden on the sync endpoints, and a data sync key (scope sync:read) gets 403 Forbidden on /api/v1/employees.
Renew, rotate and revoke
- Renew: on the API keys page, click Renew on the key and choose a new expiry date, at most the same date next year counted from today. The key itself does not change, so your job keeps working. You can renew a key until 30 days after it expired; after that, create a new key.
- Warning: the API keys page shows a warning banner from 30 days before a data sync key expires, and an error banner once it has expired. Hopshift does not send expiry emails.
- Rotate: create a new key, switch your job to it, check that it works, then revoke the old key. Both keys work in the meantime.
- Revoke: click Revoke on the key. The next request with it gets
401.
A company-scoped Admin cannot renew or revoke a whole-group key. Creating, renewing and revoking a data sync key is recorded in Hopshift's audit log.
Endpoints
All endpoints are GET requests on https://hopshift.app.
| Request | Returns |
|---|---|
/api/v1/sync/{resource} | One page of rows changed in a time window. Omit since for a full sync |
/api/v1/sync/{resource}?payroll_run_id={id} | Every row of one payroll run, for payroll-items, payroll-documents, payslip-pdfs and intercompany-recharges |
/api/v1/sync/{resource}/ids | Every id currently in scope, to find deleted rows |
/api/v1/sync/{resource}/{id}/content | One stored file, for clock-records, payroll-documents and payslip-pdfs |
{resource} is one of the 33 names in the resource reference, for example employees, clock-records or payroll-items. Any other name gets 404 with Unknown resource.
Changed-since passes
A pass reads every row of one resource that changed inside one time window, page by page. Run one pass per resource each night.
Query parameters
| Parameter | Description |
|---|---|
since | Start of the window: an ISO 8601 UTC instant ending in Z, such as 2026-11-10T16:45:00Z or 2026-11-10T16:45:00.000Z (up to 3 digits of milliseconds). It must be on or after 2000-01-01T00:00:00Z and not in the future. Date-only values, values without Z and values with an offset get 400. Omit it for a full sync. First page only |
cursor | The next_cursor of the previous page. Pass either since or cursor, never both |
limit | Rows per page. 1 to 1,000, default 500. For payroll-items: 1 to 200, default 200. It is not stored in the cursor, so pass it on every page if you do not want the default |
include | Optional field groups, comma separated. See Opt-in fields |
Response
{
"data": [{ "id": "...", "...": "..." }],
"next_cursor": "eyJ2IjoxLCJtIjoiY2hhbmdlcyIsInIiOiJlbXBsb3llZXMi...",
"window": {
"since": "2026-11-10T16:45:00.000Z",
"until": "2026-11-11T16:45:00.000Z"
}
}dataholds the rows, ordered by the moment Hopshift recorded the change, then byid.next_cursoris null on the last page of the pass.windowis the pass's window: rows whose change Hopshift recorded at or aftersinceand beforeuntil.window.sinceis null on a full sync. Every page of a pass returns the same window.
Running a pass
- Request the first page with
sinceset to thewindow.untilyou stored after the last completed pass of this resource. Hopshift fixes the window end and seals it into the cursor. - Upsert each row by
id. - While
next_cursoris not null, request the next page withcursor=<next_cursor>(and the samelimit). - When
next_cursoris null, the pass is complete. Storewindow.untilas this resource's nextsince.
Store window.until only when a pass completes. If a pass stops part way (an error, a timeout, a crash), start it again from the old since. Keep one stored since per resource.
Rows can arrive more than once: a row changed again appears in the next night's pass, and payroll rows can come both from a feed and from a run listing. Upsert by id and keep the latest copy you received. Do not skip a row because its updated_at is older than the copy you have: some Hopshift operations deliberately put back an older updated_at, for example reversing an approved attendance correction, and the feed still delivers those rows. The change order the feed uses is recorded by the database and is never the application's updated_at.
Each page is read in one consistent database snapshot. Cursors are opaque: pass them back unchanged. A cursor works only with the key and resource that issued it; anything else gets 400 with Invalid cursor. Cursors carry no access of their own, because the key's scope is checked again on every page. Cursors do not expire, so a pass that stopped can continue from its last cursor later.
Why the window ends 15 minutes ago
When a pass starts, window.until is set to 15 minutes before the first request. Hopshift records a change when the row is written, slightly before the write commits, so a very recent change might not be visible yet. The 15-minute settle lag gives every write time to commit, so a pass never steps past a change it cannot see yet.
The window can end earlier than 15 minutes ago. If a database transaction that has written data has been open for longer, window.until stops at the moment that transaction started. A long-running script on Hopshift's side can therefore give you a smaller window than usual, never a gap.
If you start a pass less than 15 minutes after the previous window.until, nothing has settled yet: the page has no rows, next_cursor is null and window.until equals your since. Storing it changes nothing.
A pass therefore never contains a row changed in the last 15 minutes, even an old row that was just edited. A row that keeps changing, for example a draft payroll run being recalculated, arrives in the first pass after it has stayed unchanged for 15 minutes. /ids has no window, so at any moment it can list a few ids your copy does not have yet, and the count of your copy can differ from the count of /ids by those rows. That is expected: they arrive in a later pass.
First full sync
Omit since on the first page. The window then runs from the beginning to 15 minutes ago, and window.since is null. Page through it like any pass, then store window.until. From then on, run nightly passes with since.
Run a full sync again whenever you want to rebuild a resource, for example after you start asking for a new include name (rows already copied are not sent again just because you ask for more fields).
Deletions
Changed-since passes never report deleted rows. Hopshift hard-deletes some rows: for example clock records removed by a correction, payslip lines replaced on recalculation, draft payroll runs, departments, positions, outlets, outlet assignments, leave allocations, salary adjustments and history rows removed with their parent. Rows also leave your scope, for example when an employee moves to a company your key does not cover, or when a payroll run is reopened.
Use /ids to catch all of these:
- Request
GET /api/v1/sync/{resource}/ids. The response is{ "data": ["<id>", ...], "next_cursor": "..." }, ordered by id, up to 10,000 ids per page (limit1 to 10,000, default 10,000). - While
next_cursoris not null, request the next page withcursor=<next_cursor>. - After the last page, delete every local row of that resource whose id was not in any page.
Do this for every resource at least weekly, and nightly if you can. Only delete with it: never create rows from /ids. /ids ignores include, since and payroll_run_id: it always lists every id of the resource currently in scope.
Soft states are ordinary updates and arrive through the passes: a deleted shift keeps its row with deleted_at set, terminated employees keep their row with their status, cancelled or rejected requests keep their status, and inactive items have is_active set to false.
Opt-in fields
Default responses leave out free text written by people, raw GPS positions, IP addresses and photos. Ask for them with include:
| Name | Accepted by | Adds |
|---|---|---|
notes | employees, clock-records, clock-record-history, shifts, overtime-requests, attendance-corrections, employee-events, salary-adjustments, transfers, leave-requests, leave-request-history, audit-events, payroll-runs, payroll-items, employee-pay-items, employee-pay-schedules, meal-allowance-policies, intercompany-assignments, intercompany-recharges, tax-declarations | Free text written by HR, managers or employees: reasons, notes, comments. The fields differ per resource |
gps | clock-records | gps_location, gps_accuracy, ip_address |
photos | clock-records | has_photo; the photo itself is the record's content |
values | audit-events | values, the recorded before and after values |
The other 13 resources accept no names. The resource reference lists the exact fields each name adds.
- Pass names comma separated:
include=notes,gps. Spaces around names and empty entries are ignored, and order does not matter. - A name the resource does not accept gets
400, and the message lists the names it does accept. - The set is sealed for a pass. The first page seals it into the cursor. On later pages, omit
include(the sealed set applies) or repeat exactly the same set. A different set, including an emptyinclude=, gets400. To change the set, start a new pass. - Opt-in fields are added after the default fields. Without
includethe keys are absent, not null. - Run listings (
?payroll_run_id=) acceptincludeand seal it the same way.
Personal evidence follows the person. A punch at a host outlet can be listed to a key that does not cover the employee who made it. On clock-records, that punch's notes, gps and photos fields are then null and its photo answers 404; on clock-record-history, notes is null for that punch. On audit-events, reason and values are null when the employee the row is about belongs to a company outside the key's companies or can no longer be found, and also when the row is about a payroll run or payslip whose run is not currently FINALIZED. Inside values, free-text keys are removed unless you also ask for notes.
Files
GET /api/v1/sync/{resource}/{id}/content returns one file, where {id} is the row's id from the feed.
| Resource | File | Content-Type |
|---|---|---|
clock-records | The punch's face photo, when has_photo is true | The stored type, else from the extension (image/jpeg, image/png, image/webp). The filename is <clock record id>.<extension> |
payroll-documents | One government filing or payroll export, see below | Fixed per type |
payslip-pdfs | The payslip PDF as stored, when pdf_available is true | application/pdf |
Payroll documents are served for FINALIZED runs only:
type | File | Content-Type |
|---|---|---|
PND1 | RD e-filing text for PND1 in TIS-620: one line per employee and no header row, fields separated by | with a trailing | on every line, and CRLF after every line including the last. Served byte for byte as stored | application/octet-stream |
PND1_PDF | PND1 form, not password protected | application/pdf |
SSO_1_10 | SSO 1-10 contribution schedule: CSV in UTF-8 with its byte order mark, LF line endings | text/csv; charset=utf-8 |
SSO_1_10_PDF | SSO 1-10 form, not password protected | application/pdf |
SSO_SUBMISSION | SSO submission file, TIS-620 text, served as stored | application/octet-stream |
PAYROLL_CSV | Payroll export: CSV in UTF-8 without a byte order mark, LF line endings | text/csv; charset=utf-8 |
The PAYSLIPS_ZIP document (every payslip in one archive) is never listed or served. Use payslip-pdfs instead.
Payslip PDFs are password protected (AES-256), exactly as employees receive them. The password is the employee's birthday as stored in Hopshift when the PDF was protected, written as DDMMYYYY with the Gregorian year: 15 January 1990 gives 15011990. A payslip whose employee had no birthday on file has no PDF (pdf_available is false). Each payslip-pdfs row carries pdf_sha256, the SHA-256 of the file, so you can check the download; Hopshift checks it too before serving and answers 500 on a mismatch.
Every file response has these headers:
Content-Typeas above, andContent-LengthContent-Disposition: attachment; filename="<ASCII name>"; filename*=UTF-8''<exact name>Cache-Control: private, no-storeandX-Content-Type-Options: nosniff
A file is read when you request it, with your key's scope checked again. These get 404:
- a resource that stores no files (
This resource has no content) - an id that does not exist or is outside your key's scope (
Not found) - a payroll document of a run that is not
FINALIZEDor no longer exists, andPAYSLIPS_ZIP - a payslip PDF superseded by a later protection of the same payslip, or one without a PDF
- a clock record without a photo, whose photo is gone from storage, or whose employee is outside your key's companies
Payroll of record
Payroll resources serve finalised runs only. A draft run changes on every recalculation and is not payroll of record.
payroll-runslists runs of every status. A run's status, publication revision and publication are fields of the run, so finalising, reopening or republishing a run sends it again in thepayroll-runsfeed.payroll-items(payslips with their lines),payroll-documents,payslip-pdfsandintercompany-rechargesonly contain rows of runs that areFINALIZED. Finalising or reopening a run does not send those rows again, so follow the runs feed:
- Run the
payroll-runspass. - For each run you receive with status
FINALIZED(including a republished run with a newpublication_revision), replace every local payslip, payroll document, payslip PDF and intercompany recharge of that run with the run listings:payroll-items?payroll_run_id=<id>,payroll-documents?payroll_run_id=<id>,payslip-pdfs?payroll_run_id=<id>andintercompany-recharges?payroll_run_id=<id>. A republished run lists only its new publication's recharges, so replacing drops the earlier ones. - For each run you receive with any other status, delete that run's local payslips, payroll documents, payslip PDFs and recharges.
- Also run the changed-since passes of those four resources. They deliver later changes to rows of finalised runs: a payslip edited after publication, a payslip line changed on a finalised payslip, a regenerated filing, a payslip PDF protected again (for example after a birthday correction) and a recharge's own changes (confirmed, invoiced, settled, a line updated or removed).
- A
payslip-pdfsrow replaces any earlier row with the samepayroll_item_id. Download its file whenpdf_availableis true. - Run
/idsof the four resources weekly as for every resource.
On the first full sync every finalised run is new, so the rule lists all of them: about four feed requests per run, for example 448 requests for 112 runs. That fits easily in a day's budget.
A run listing returns every row of that run currently in scope, in id order, paged by limit (the resource's own maximum) and next_cursor. It has no window, so it includes rows written in the last 15 minutes, and its response has no window: a listing never moves your stored since. On later pages, repeat payroll_run_id and add cursor. A run that is not FINALIZED or does not exist lists nothing, and so does a run of a company outside your key (for intercompany-recharges, a run whose source and host companies are both outside your key). since together with payroll_run_id gets 400, and so does a listing cursor on a feed or the other way round. Other resources ignore payroll_run_id.
Keys that hold only the host company of a recharge. An intercompany recharge belongs to the source company's payroll run. A key that covers the host company but not the source company sees the recharge with payroll_run_id null and never sees the run, so it cannot apply the run rule. Such a key removes reopened or superseded recharges through /ids of intercompany-recharges instead.
Values and references
Formats
| Kind | Format | Example |
|---|---|---|
| Field names | snake_case | hire_date |
| Ids | Opaque strings. Never parse them | |
| Calendar dates | YYYY-MM-DD | 2026-11-01 |
| Instants | ISO 8601 in UTC with milliseconds and Z | 2026-11-11T01:58:12.000Z |
| Wall-clock times | HH:mm | 09:00 |
| Money | String with exactly 2 decimals, never a float | "15000.00" |
| Other decimals | String at the stored scale: hours 2 decimals, leave days 1 decimal, rates, factors and percentages as listed per field | "7.50", "0.5", "0.9677" |
| Enum values | As stored, so casing varies between fields | ACTIVE, full_time, pending |
| Booleans, integers, other numbers | JSON values | true, 30, 12.5 |
Treat every field except id as nullable.
Some dates are stored as timestamps (an employee's hire, probation, contract, termination and wage dates, event and effective dates, transfer dates, outlet assignment dates). They are returned as the calendar day in the company's timezone. A company's timezone is the timezone of its oldest outlet, else Asia/Bangkok for a company in Thailand, Asia/Singapore for Singapore, else UTC.
References outside your key
A field that points at a row in a company outside your key's companies is null, for example an employee's manager_id or transferred_from_employee_id, or a clock record's employee_id, location_id and shift_id when the punch was made at a host outlet by another company's employee. References to group-level rows of another group are null too. An employee's department_id and position_id are only sent when they belong to the employee's own company.
People with several employee records
Moving an employee to another company creates a new employee record with a new id in the target company. The old record keeps its id and its history. The API does not merge them; link them through the new record's transferred_from_employee_id and through transfers, where employee_id is the source record and target_employee_id the new one. A side outside your key's companies is null.
Who did it
Fields ending in _by_user_id (reviewer, approver, editor, actor) are Hopshift user ids. Join them to employees.user_id. Admins who have no employee record in your key's companies stay as plain ids: there is no users resource.
Values worked out at read time
A row is sent again only when the row itself changes. A few values depend on other data when the row is read, and changes to that other data do not send the row again:
- Employee and other calendar dates use the company's timezone, and a shift's
timezoneis its outlet's (else its company's). If a timezone changes, run a full sync ofemployees,employee-events,employee-locations,salary-adjustments,transfersandshifts. - Whether a reference is sent or
nulldepends on where the referenced row is at read time. When it moves into or out of your key's companies, the rows that point at it are not sent again. - On
audit-events,reasonandvaluesdepend on the run's current status and on the company of the employee the row is about. If you need that detail, run a full sync ofaudit-eventsafter a run you follow is finalised. - If a company moves to another group (a rare admin operation), none of its rows change. Run a full sync and
/idsof every resource after such a move.
A periodic full sync of every resource, for example once a month, corrects all of these.
Limits
Each key has two budgets, counted across all Hopshift servers over the last minute and the last 24 hours:
| Budget | Requests | Per minute | Per day |
|---|---|---|---|
| Feed | Feed pages, run listings and /ids pages | 60 | 5,000 |
| Content | /content downloads | 120 | 40,000 |
Downloads never use up the feed budget. Every request that gets past authentication counts, including ones answered with 400, 404 or 500. Requests refused with 401, 403 or 429 do not count.
Past a limit you get 429 Too Many Requests with a Retry-After header: the number of seconds until the oldest request in the full window ages out. The message is Too many requests or Daily request limit reached for the feed budget, and Too many content requests or Daily content request limit reached for downloads. Send requests one at a time rather than in parallel, wait for Retry-After before retrying, and schedule the job outside Thai business hours.
Hopshift records every counted request with its key, resource, status, row count, size, duration, IP address and user agent. Query parameters, including cursors, are not recorded.
Errors
Every error is JSON with one field. The text is for people reading logs and can change, so handle errors by status code:
{ "error": "Invalid cursor" }| Status | When |
|---|---|
400 Bad Request | Invalid since, cursor, limit, include or payroll_run_id; since together with cursor or with payroll_run_id; an include set different from the pass's sealed set |
401 Unauthorized | Unauthorized: the header is missing or not Bearer hops_..., or the key is unknown, revoked or expired |
403 Forbidden | Forbidden: missing sync:read scope: the key is not a data sync key |
404 Not Found | Unknown resource, This resource has no content or Not found (a file that does not exist or is outside your key) |
429 Too Many Requests | A rate limit, see Limits |
500 Internal Server Error | An unexpected error on Hopshift's side. Retry later; if it persists, contact Hopshift support |
Recommended nightly job
- Schedule it once a night, outside Thai business hours, and run requests one at a time.
- Sync the resources in this order, so referenced rows usually arrive first:
- structure:
companies,locations,departments,level-tiers,positions,leave-types,pay-item-definitions,pay-items,meal-allowance-policies - people:
employees,employee-locations,employee-events,salary-adjustments,transfers - attendance and leave:
shifts,clock-records,clock-record-history,overtime-requests,attendance-corrections,leave-requests,leave-request-history,leave-allocations - payroll:
payroll-runs, thenpayroll-items,payroll-documents,payslip-pdfs,intercompany-recharges, thenemployee-pay-items,employee-pay-schedules,meal-allowance-enrollments,intercompany-assignments,tax-declarations - history:
audit-events
- structure:
- For each resource, run a pass from its stored
since(none on the first run) and storewindow.untilwhen the pass completes. - After the
payroll-runspass, apply the payroll rule to every run it returned. - Download new files: photos where
has_photois true (it is only sent withinclude=photos), payroll documents, and payslip PDFs wherepdf_availableis true. Every photo is one download, so the first download of all photos can use most of a day's content budget: 35,000 photos take about 5 hours at 120 a minute. Spread that first download over several nights, or fetch photos only when you need them. - Weekly or nightly, run
/idsfor every resource and delete local rows whose id is absent. - On
429, waitRetry-Afterseconds and repeat the same request. On500or a network error, retry later; if a pass cannot finish, keep the oldsinceand start it again next time.
References can point at rows your copy does not have yet, or never will when they are outside your key, so avoid strict foreign keys between synced tables.
for resource in RESOURCES: # in the order above
since = stored_until.get(resource) # None on the first run
params = limit={MAX_LIMIT[resource]} # limit is not kept in the cursor
if INCLUDE.get(resource): # for example "notes,photos"
params += &include={INCLUDE[resource]} # the same set on every page
page = GET /api/v1/sync/{resource}?since={since}&{params} # omit since when None
while True:
upsert(resource, page.data) # by id
if resource == "payroll-runs":
apply_payroll_rule(page.data)
if page.next_cursor is None:
break
page = GET /api/v1/sync/{resource}?cursor={page.next_cursor}&{params}
stored_until[resource] = page.window.until # only after the pass completes
weekly, for resource in RESOURCES:
ids = all ids from /api/v1/sync/{resource}/ids, following next_cursor
delete local rows of resource whose id is not in idsExamples
The ids below are placeholders.
Read a page of changed clock records
curl "https://hopshift.app/api/v1/sync/clock-records?since=2026-11-10T16:45:00.000Z&limit=500&include=notes,gps" \
-H "Authorization: Bearer hops_your_api_key_here"{
"data": [
{
"id": "example-clock-record-1",
"employee_id": "example-employee-1",
"company_id": "example-company-1",
"location_id": "example-location-1",
"shift_id": "example-shift-1",
"type": "clock_in",
"record_time": "2026-11-11T01:58:12.000Z",
"source": "web",
"approval_status": "approved",
"approval_reason_code": null,
"reviewed_by_user_id": null,
"reviewed_at": null,
"edited_by_user_id": null,
"edited_at": null,
"original_time": null,
"is_out_of_schedule": false,
"is_location_unverified": false,
"created_at": "2026-11-11T01:58:13.000Z",
"updated_at": "2026-11-11T01:58:13.000Z",
"notes": null,
"manager_notes": null,
"approval_reason": null,
"gps_location": "13.7000,100.5000",
"gps_accuracy": 12.5,
"ip_address": "203.0.113.10"
}
],
"next_cursor": "eyJ2IjoxLCJtIjoiY2hhbmdlcyIsInIiOiJjbG9jay1yZWNvcmRzIi...",
"window": {
"since": "2026-11-10T16:45:00.000Z",
"until": "2026-11-11T16:45:00.000Z"
}
}Continue the pass with the cursor. include can be omitted because the first page sealed it:
curl "https://hopshift.app/api/v1/sync/clock-records?cursor=eyJ2IjoxLCJtIjoiY2hhbmdlcyIsInIiOiJjbG9jay1yZWNvcmRzIi...&limit=500" \
-H "Authorization: Bearer hops_your_api_key_here"List a finalised run's filings and download one
curl "https://hopshift.app/api/v1/sync/payroll-documents?payroll_run_id=example-run-1" \
-H "Authorization: Bearer hops_your_api_key_here"{
"data": [
{
"id": "example-document-1",
"payroll_run_id": "example-run-1",
"company_id": "example-company-1",
"type": "PND1",
"filename": "PND1_example.txt",
"content_type": "application/octet-stream",
"generated_at": "2026-11-01T03:00:00.000Z"
}
],
"next_cursor": null
}curl -o PND1_example.txt \
"https://hopshift.app/api/v1/sync/payroll-documents/example-document-1/content" \
-H "Authorization: Bearer hops_your_api_key_here"The saved file is the stored PND1 file, byte for byte, in TIS-620.