Hopshift
API Reference

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.

  1. Open Group Settings → API Keys.
  2. Click Create API Key.
  3. Enter a Key Name, for example Nightly sync - Production.
  4. Under Key type, choose Data sync (read-only).
  5. 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.
  6. 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.

RequestReturns
/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}/idsEvery id currently in scope, to find deleted rows
/api/v1/sync/{resource}/{id}/contentOne 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

ParameterDescription
sinceStart 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
cursorThe next_cursor of the previous page. Pass either since or cursor, never both
limitRows 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
includeOptional 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"
  }
}
  • data holds the rows, ordered by the moment Hopshift recorded the change, then by id.
  • next_cursor is null on the last page of the pass.
  • window is the pass's window: rows whose change Hopshift recorded at or after since and before until. window.since is null on a full sync. Every page of a pass returns the same window.

Running a pass

  1. Request the first page with since set to the window.until you stored after the last completed pass of this resource. Hopshift fixes the window end and seals it into the cursor.
  2. Upsert each row by id.
  3. While next_cursor is not null, request the next page with cursor=<next_cursor> (and the same limit).
  4. When next_cursor is null, the pass is complete. Store window.until as this resource's next since.

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:

  1. Request GET /api/v1/sync/{resource}/ids. The response is { "data": ["<id>", ...], "next_cursor": "..." }, ordered by id, up to 10,000 ids per page (limit 1 to 10,000, default 10,000).
  2. While next_cursor is not null, request the next page with cursor=<next_cursor>.
  3. 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:

NameAccepted byAdds
notesemployees, 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-declarationsFree text written by HR, managers or employees: reasons, notes, comments. The fields differ per resource
gpsclock-recordsgps_location, gps_accuracy, ip_address
photosclock-recordshas_photo; the photo itself is the record's content
valuesaudit-eventsvalues, 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 empty include=, gets 400. To change the set, start a new pass.
  • Opt-in fields are added after the default fields. Without include the keys are absent, not null.
  • Run listings (?payroll_run_id=) accept include and 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.

ResourceFileContent-Type
clock-recordsThe punch's face photo, when has_photo is trueThe stored type, else from the extension (image/jpeg, image/png, image/webp). The filename is <clock record id>.<extension>
payroll-documentsOne government filing or payroll export, see belowFixed per type
payslip-pdfsThe payslip PDF as stored, when pdf_available is trueapplication/pdf

Payroll documents are served for FINALIZED runs only:

typeFileContent-Type
PND1RD 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 storedapplication/octet-stream
PND1_PDFPND1 form, not password protectedapplication/pdf
SSO_1_10SSO 1-10 contribution schedule: CSV in UTF-8 with its byte order mark, LF line endingstext/csv; charset=utf-8
SSO_1_10_PDFSSO 1-10 form, not password protectedapplication/pdf
SSO_SUBMISSIONSSO submission file, TIS-620 text, served as storedapplication/octet-stream
PAYROLL_CSVPayroll export: CSV in UTF-8 without a byte order mark, LF line endingstext/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-Type as above, and Content-Length
  • Content-Disposition: attachment; filename="<ASCII name>"; filename*=UTF-8''<exact name>
  • Cache-Control: private, no-store and X-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 FINALIZED or no longer exists, and PAYSLIPS_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-runs lists 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 the payroll-runs feed.
  • payroll-items (payslips with their lines), payroll-documents, payslip-pdfs and intercompany-recharges only contain rows of runs that are FINALIZED. Finalising or reopening a run does not send those rows again, so follow the runs feed:
  1. Run the payroll-runs pass.
  2. For each run you receive with status FINALIZED (including a republished run with a new publication_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> and intercompany-recharges?payroll_run_id=<id>. A republished run lists only its new publication's recharges, so replacing drops the earlier ones.
  3. For each run you receive with any other status, delete that run's local payslips, payroll documents, payslip PDFs and recharges.
  4. 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).
  5. A payslip-pdfs row replaces any earlier row with the same payroll_item_id. Download its file when pdf_available is true.
  6. Run /ids of 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

KindFormatExample
Field namessnake_casehire_date
IdsOpaque strings. Never parse them
Calendar datesYYYY-MM-DD2026-11-01
InstantsISO 8601 in UTC with milliseconds and Z2026-11-11T01:58:12.000Z
Wall-clock timesHH:mm09:00
MoneyString with exactly 2 decimals, never a float"15000.00"
Other decimalsString 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 valuesAs stored, so casing varies between fieldsACTIVE, full_time, pending
Booleans, integers, other numbersJSON valuestrue, 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 timezone is its outlet's (else its company's). If a timezone changes, run a full sync of employees, employee-events, employee-locations, salary-adjustments, transfers and shifts.
  • Whether a reference is sent or null depends 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, reason and values depend 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 of audit-events after 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 /ids of 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:

BudgetRequestsPer minutePer day
FeedFeed pages, run listings and /ids pages605,000
Content/content downloads12040,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" }
StatusWhen
400 Bad RequestInvalid 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 UnauthorizedUnauthorized: the header is missing or not Bearer hops_..., or the key is unknown, revoked or expired
403 ForbiddenForbidden: missing sync:read scope: the key is not a data sync key
404 Not FoundUnknown resource, This resource has no content or Not found (a file that does not exist or is outside your key)
429 Too Many RequestsA rate limit, see Limits
500 Internal Server ErrorAn unexpected error on Hopshift's side. Retry later; if it persists, contact Hopshift support
  1. Schedule it once a night, outside Thai business hours, and run requests one at a time.
  2. 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, then payroll-items, payroll-documents, payslip-pdfs, intercompany-recharges, then employee-pay-items, employee-pay-schedules, meal-allowance-enrollments, intercompany-assignments, tax-declarations
    • history: audit-events
  3. For each resource, run a pass from its stored since (none on the first run) and store window.until when the pass completes.
  4. After the payroll-runs pass, apply the payroll rule to every run it returned.
  5. Download new files: photos where has_photo is true (it is only sent with include=photos), payroll documents, and payslip PDFs where pdf_available is 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.
  6. Weekly or nightly, run /ids for every resource and delete local rows whose id is absent.
  7. On 429, wait Retry-After seconds and repeat the same request. On 500 or a network error, retry later; if a pass cannot finish, keep the old since and 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 ids

Examples

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.

On this page