Time & attendance
BioTime Integration Guide — HumanR
How to connect HumanR to a site that already runs ZKTeco BioTime as its device server, so the punches BioTime collects reach HumanR's attendance without touching a single terminal. Written for the IT head who owns the BioTime server and the network between it and HumanR; the HR steps (mapping PINs, enabling terminals) are here too. For terminals that push straight to HumanR, read the ZKTeco integration guide instead.
This is the guide HumanR users read inside the product, published as-is. It is written for someone with the screen in front of them, so it describes buttons you cannot click from here — which is rather the point: you can check how the product behaves before you commit to it.
1. How it fits together
- BioTime stays the device server. A ZK terminal can push to only one host. Where that host is already BioTime — with enrolment, device settings, shift rules and the terminal list living there — HumanR does not ask for the terminals to be repointed. It logs in to BioTime's REST API as an ordinary API user and reads the transactions (punches) BioTime has already stored.
- Pull, not push. HumanR calls BioTime; BioTime never calls HumanR. Nothing inbound needs opening on the HumanR side, and BioTime needs no plugin, webhook or database access.
- Same landing path as a direct push. A pulled punch becomes the same punch row a pushing terminal would create: same (terminal, PIN, time) duplicate key, same terminal whitelist (new terminals arrive disabled), same Unmapped PINs page, same rollup into attendance days, same overtime and late-fine arithmetic. HR sees no difference downstream.
- Both paths at once is fine. One site can push straight to
/iclock/cdatawhile another sits behind BioTime. Several BioTime servers are supported too — each is a source with its own code, credentials, schedule and cursor. - It is a module. Behind the
EnableBioTimeSyncflag. Off is nothing: no page, no worker, no stored credentials used, no outbound call. The direct push path is unaffected either way.
2. What IT needs to have ready
| Item | Detail |
|---|---|
| BioTime root URL, as seen from the HumanR server | The address you log in to in a browser, with no path: http://biotime.example.local:8080.
Absolute, http or https, no query string. If BioTime sits
behind a reverse proxy that redirects http → https, give HumanR the final URL — it does not
follow redirects, by design (§12). |
| Network path | Outbound from the HumanR server to the BioTime host and port (BioTime's default web port is 8080; 80/443 behind a proxy). Nothing inbound to HumanR. If the two are on different segments or sites, open that one rule. Devices are untouched. |
| BioTime version | 8.x or 9.x. 8.x issues API tokens at /api-token-auth/; 9.x at
/jwt-api-token-auth/. HumanR tries the first and falls back to the second, so you
do not have to tell it — but knowing which you run shortens any troubleshooting. |
| API module on the BioTime licence | BioTime answers 403 to every API call when the API module is not licensed — on a trial or base licence, for instance. HumanR reports this as API not licensed. Confirm with your ZKTeco reseller before the go-live date; nothing in HumanR can work around it. |
| A dedicated, read-only BioTime user | Made for HumanR (§3). Not a person's account: when they leave or reset their password the pull stops. |
| Clock and time zone | BioTime's server clock in the same zone as HumanR's configured time zone, and the terminals in step with BioTime. Punch times are copied as the wall clock BioTime reports, with no conversion — a zone mismatch would shift every punch by hours. Test connection shows BioTime's newest punch time beside HumanR's clock so this is checked before any punch is stored. |
| Employee codes in BioTime | A HumanR device PIN is a whole number. A BioTime emp_code that is
numeric becomes the PIN as-is; one that equals the person's HumanR staff number is mapped automatically by
the import (§8). Codes with letters cannot be PINs — HumanR counts and
lists them so they can be renumbered in BioTime or mapped by hand. |
| Terminal serials | Nothing to prepare; BioTime reports each punch's terminal_sn. Those serials
appear in HumanR disabled until HR enables them (§7). |
| HumanR side | The module flag flipped and the devices.manage permission granted to whoever
will administer the sources (§4). |
Quick connectivity check from the HumanR server
Run these on the HumanR server (or inside its container), not from your desk — the URL has to resolve and route from there. Replace host, user and password.
# 8.x — expect {"token":"…"}
curl -s -X POST http://biotime.example.local:8080/api-token-auth/ \
-H 'Content-Type: application/json' -d '{"username":"humanr","password":"<password>"}'
# 9.x — same shape, different path
curl -s -X POST http://biotime.example.local:8080/jwt-api-token-auth/ \
-H 'Content-Type: application/json' -d '{"username":"humanr","password":"<password>"}'
| You get | It means |
|---|---|
{"token":"…"} | Credentials and licence fine; use this URL in HumanR. |
HTTP 400 with non_field_errors | Wrong username or password. |
| HTTP 403 | API module missing from the BioTime licence. |
| HTTP 404 on both paths | Not BioTime's root URL — wrong port, a sub-path, or a proxy that serves something else at /. |
| HTTP 301/302 | A redirect (usually to https). Use the redirected-to URL. |
| Connection refused / timeout | Network: firewall, wrong host, BioTime not running. |
3. Create the API user in BioTime
In BioTime's web console (menu names vary slightly between 8.x and 9.x):
- System → User (9.x: System → User Management) → New. Username
humanr(or your convention), a long random password, Active ticked. Do not make it a superuser. - Give it a role that can only view: Personnel → Employee, Device → Terminal and Attendance → Transaction. Nothing else is read. If your version offers Area scoping, give it every area whose terminals should reach HumanR — a transaction outside the user's areas is simply not returned, and nothing in HumanR will tell you it exists.
- If the licence shows the API module as absent, stop here and sort that out first (§2).
- Run the
curlcheck above with the new user. Keep the password for the HumanR form in §5 — it is entered once there, sealed at rest, and never shown again. Do not paste it into email or chat.
4. Turn the module on in HumanR
Turning a module on is three steps, and the third is the one that gets missed.
- Deploy a build that has the module. The sync-run table and the device source column arrive with the migrations on start.
- Flip the flag
EnableBioTimeSync, the way flags are set for that kind of install: a cloud tenant on that instance's Configuration screen in HumanR.Office (it is rendered into the tenant's environment file; a hand-added line is overwritten by the next render); a native install inappsettings.Production.json(Features:EnableBioTimeSync = true) or its environment file (Features__EnableBioTimeSync=true); development inappsettings.Development.json. Restart the app; the worker registers at startup. - Grant the permission. Admin → Roles & permissions:
give Devices → manage (
devices.manage) to the role that administers terminals. Admin has it already; the HR role's default set deliberately does not, and role defaults only seed a role with no claims at all, so nobody gains it on an existing install until you grant it.
Check: for a user with that role, Attendance → Devices now shows a BioTime sync
link, and Maintenance → Background workers lists
HumanR.Web.Services.BioTime.BioTimeSyncHostedService.
5. Add the BioTime server
Attendance → Devices → BioTime sync → Add a BioTime server.
| Field | What to enter | Rules |
|---|---|---|
| Code | A short stable name: HQ, SITE2, FACTORY |
2–20 letters, digits, - or _; upper-cased on save. Runs,
terminals and the pseudo terminal BIOTIME-{code} are filed under it, so pick it once
and leave it. |
| Name | For people: “Head office BioTime” | Free text. |
| Server URL | http://biotime.example.local:8080 |
Absolute, http/https, no path or query. The URL from §2, as the HumanR server sees it. |
| API username | The user from §3 | — |
| Password | Its password | Required when adding. On edit the box is blank and means “keep the stored one” — type a new one only to change it. Sealed with Data Protection at rest, never displayed, never logged, never in the audit log. |
| every … min | How often the scheduled pull runs | 1–1440, default 5. Five minutes is plenty — BioTime itself only receives punches as terminals upload them. |
| lookback … days | How far behind the cursor each run re-reads | 0–30, default 3. This is what catches a terminal that uploaded late (§9). Raise it for a site with flaky connectivity. |
| page size | Rows per API page | 50–5000, default 1000. Lower it only if BioTime times out on large pages. |
| Enabled | Whether the schedule runs | Leave off until Test connection passes and the terminals are enabled (§7). Sync now, Backfill and Import PINs work on a disabled source — the admin clicked. |
Save → “Source HQ added. Use Test connection before enabling it.” The audit log records who added or changed a source, its host, user and settings, and whether the password changed — never the password itself.
6. Test connection, and what each answer means
Test connection logs in, lists the terminals, counts the last 24 hours of transactions and reads the newest punch time. It stores nothing and leaves no run row, so run it as often as you like.
Success reads like:
HQ: connected (Token login) — 2 terminal(s) [Gate A, Gate B], 318 transaction(s) in the last 24 h. BioTime's latest punch_time in the sample: 2026-10-01 08:57:12 · HumanR's clock now: 2026-10-01 09:01:40 (Asia/Dhaka) — they should be in the same zone.
Compare the two timestamps: the newest punch should be a few minutes behind HumanR's clock, not hours either side. If it is off by a round number of hours the zones differ — fix BioTime's server zone or HumanR's time zone before any punch is stored. “Token login” means 8.x, “JWT login” means 9.x.
| The flash says | Cause | Do this |
|---|---|---|
| credentials rejected — BioTime rejected the username or password. | Wrong user or password, or the user is inactive. | Re-check in BioTime; re-enter the password and Save. |
| API not licensed — BioTime answered 403 to the login… | The API module is missing from the BioTime licence. | Reseller. Nothing to change in HumanR. |
| API not licensed — BioTime answered 403 — the API module is not licensed on this BioTime, or this user may not read this data. | Login worked, a later call was refused: the role lacks view on Employees, Terminals or Transactions. | Fix the role (§3). |
| not authorised — BioTime refused the token it had just issued — the API user may be disabled. | BioTime issued a token then rejected it, twice. | Check the user is Active; check BioTime's own logs. |
| unreachable — Could not reach http://host:8080: … | DNS, connection refused, TLS. | Network path from the HumanR server (§2 check). |
| unreachable — http://host:8080 did not answer within 30 s. | BioTime is up but slow, or a firewall is silently dropping. | Check BioTime load; try a smaller page size; check the firewall drops versus rejects. |
| unexpected answer — Neither /api-token-auth/ nor /jwt-api-token-auth/ exists at this address — is it the BioTime server's root URL? | Both login paths 404: wrong port, a sub-path, or a proxy. | Use the root URL. |
| unexpected answer — … (a 3xx, HTML, or non-JSON body) | A redirect — usually http → https — or a login page served by a proxy. | Enter the final URL; HumanR never follows redirects. |
| BioTime error — … | BioTime answered 5xx. | BioTime's own logs; retry once it is healthy. |
| HQ: the stored password cannot be read on this instance — re-enter it and save. | This instance's Data Protection key ring cannot open the sealed password: a database copied from another environment, or a rotated key. | Re-enter the password and Save. Expected on a sandbox rebuilt from a production dump — it is why a sandbox cannot reach production BioTime by accident. |
7. First sync, enable the terminals, backfill, then schedule
- Sync now. Flash: “HQ: sync queued — the worker picks it up within a minute.” The first run reads the last lookback days (three by default) up to now. Refresh; a run row appears with its counts.
- Every terminal BioTime reports is now on
Attendance → Devices, disabled, tagged
via BioTime HQ, named after BioTime's terminal alias (or
(new) <serial>when there is none). Punches with no terminal serial — mobile-app or API punches in BioTime — file under a pseudo terminalBIOTIME-HQ, which arrives the same way. While a terminal is disabled its punches are skipped and counted (“N skipped — terminal disabled” on the run row), never stored. The sync page warns: “N terminal(s) from this source are registered but disabled, so their punches are being skipped.” - Enable the terminals you want: on Attendance → Devices, give each a name and work site, tick Enabled, Save. Leave a terminal disabled to keep its punches out — a test unit, a canteen reader.
- Backfill the days you missed while they were disabled: pick from and to on the source card. Flash: “HQ: backfill of 24 Sep 2026 – 01 Oct 2026 queued. Terminals still disabled are skipped — enable them first.” A backfill re-reads an explicit window — at most 62 days per request, not into the future — stores what is missing, and never moves the cursor. Run it in pieces for a longer history.
- Tick Enabled on the source and Save. From now on the worker pulls every poll minutes on its own. The badge changes from “Disabled — no scheduled runs” to the run status.
- Hand over to HR for PIN mapping (§8) and a check of Attendance → Punches for one known employee against the terminal's own log.
8. Import PINs from BioTime
A punch names a BioTime employee code; HumanR needs to know which employee that is. The direct-push guide does this one PIN at a time on Unmapped PINs. With BioTime, the Import PINs from BioTime button on the source card fills the whole list in for review.
For each BioTime employee (up to 20,000 are read) HumanR proposes who they are:
| Match | Rule |
|---|---|
| by staff no. | BioTime emp_code equals a HumanR staff number (case-insensitive). The strong match — if your BioTime codes are the staff numbers, nearly every row lands here. |
| by name | Otherwise, BioTime first + last name equals exactly one employee's full name, whole words, no fuzzy matching. Two employees with the same name → no proposal; pick by hand. |
| none | Pick the employee from the search box on the row, or leave unticked. |
The proposed PIN is the numeric emp_code. The Note column
tells you when a row cannot be applied:
- “cannot be a PIN” — the code has letters. HumanR PINs are whole numbers. Renumber in BioTime or map by hand later (those codes are also listed on the sync page after each run).
- “already holds PIN … — change it on the Unmapped PINs page if that is wrong” — this employee is mapped already; left alone.
- “PIN … is held by E-0042 (…) — conflict, not applied” — a different employee already holds that number. Resolve in BioTime or on Unmapped PINs; the import will not overwrite a mapping.
Tick the rows you accept → Apply ticked rows. Each is re-checked at apply time (employee exists, holds no other PIN, nobody else holds this one), the PIN is set, every earlier punch stored under that PIN with no employee is claimed, and those days are rolled up (back to 62 days). Flash: “14 PIN(s) set, 1,203 earlier punch(es) claimed.” — followed by “Refused: …” for anything that failed its re-check. The import shows names, codes and departments only; BioTime's employee device passwords are never read.
Nothing stops HR using the ordinary Unmapped PINs page instead or as well — it is the same mapping.
9. How a run behaves
- Cursor. Each source remembers the furthest time it has stored, across every completed run. A scheduled or Sync now run reads from the cursor minus the lookback, up to now. The first run has no cursor and reads the last lookback days. A cursor that is somehow ahead of now (a clock that moved back) is treated as absent.
- Day slices. The window is walked one day at a time, each day paged by page size, ordered by punch time then id. After a day's rows are saved, the cursor advances to the end of that day — so a run that dies on day 40 of 60 keeps 39, and the next run resumes from there minus the lookback. A pause of any length — an outage, a licence hold, a stopped server — is caught up on the next run without a backfill.
- Lookback. BioTime filters transactions on punch time, not on when the terminal uploaded them. A terminal that was offline and uploads yesterday's punches today is caught because the run re-reads the last lookback days. A terminal offline longer than the lookback is the one case a scheduled run misses: raise the lookback for that site, or Backfill the gap.
- Per row. Blank
terminal_sn→ pseudo terminalBIOTIME-{code}. Unknown serial → registered disabled with BioTime's alias as its name. Disabled terminal → skipped, counted.emp_codewith letters → counted, first ten listed, no row. Otherwise the punch is stored with the code as its PIN, the employee holding that PIN (or none → Unmapped), the punch time as BioTime reports it, BioTime'spunch_stateandverify_type, and a raw linebiotime:{code}:{id}…for provenance. A punch already present for that terminal, PIN and time is a duplicate and counted as “already stored”. - Rollup. When a run inserted anything, the days it touched are recomputed (in 31-day chunks), so Present/Absent and overtime follow within the run, not fifteen minutes later.
- Limits. 5,000 pages per day-slice and 250,000 rows per run fail the run with a clear error — the first means BioTime ignored the page size; the second says to narrow the window with a backfill. Each HTTP call has a 30-second timeout. Login happens once per run; a token BioTime stops honouring mid-run is refreshed once.
- One at a time. A source never runs twice at once. Sync now while a run is in progress is refused with “already running (since …)”; while one is queued, “a sync is already queued”. A Sync now stands in for that tick's scheduled run; a backfill does not.
- Licence stand-down. While the HumanR licence is read-only or blocked the worker stands down and resumes afterwards; the cursor design means nothing is lost. It does not stand down in a sandbox's log-only mode — a pull is a read, and a sandbox cannot open a production password anyway (§6, last row).
- Run history is kept for 30 days, except the row holding each source's cursor, and pruned every six hours. Deleting a source stops the pulls; the terminals it registered and the punches it pulled stay.
10. Monitoring
The sync page (Attendance → Devices → BioTime sync). Per source: a status badge — never run, ok, failing, running — plus Disabled — no scheduled runs and queued where they apply; URL, user, schedule; last run, last success, cursor; and the last run's counts: fetched / new / already stored / unmapped PIN(s), with skipped — terminal disabled, non-numeric code(s) and new terminal(s) when non-zero. Warnings call out an unreadable password, a failed last run with its error, and terminals awaiting enabling. Below, the last 20 runs: Started, Source, Trigger (scheduled / manual / backfill), Window, Fetched, New, Dupes, Unmapped, Skipped, Result.
Maintenance → Background workers (/ops/maintenance): lists
HumanR.Web.Services.BioTime.BioTimeSyncHostedService — “Every 60 s tick; each BioTime source
on its own poll interval, default 5 min (30 s after startup); prunes run history every 6 h” — with its last tick and
result.
Health (/ops/health): the worker's last run and last failure. A source that
fails marks the tick failed, so a wrong password left for a week is what the health watchdog exists
to catch.
Logs: one information line per run — BioTime sync HQ (scheduled): fetched 318,
inserted 12, duplicates 306, unmapped 0 — and a warning or error per failed source. Credentials and tokens are
never logged.
Downstream: Attendance → Unmapped PINs for codes no employee holds; Attendance → Devices for terminals (the via BioTime badge marks pulled ones); Attendance → Punches for the rows.
11. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Test connection: credentials rejected | Wrong user/password, or inactive user. | §6. |
| Test connection: API not licensed | 403 — the API module is not on the BioTime licence, or (after a successful login) the role cannot view that data. | Reseller for the licence; §3 for the role. |
| Test connection: unreachable | Network from the HumanR server, or a 30 s timeout. | §2 check from the server itself. |
| Test connection: unexpected answer — neither login path exists | Not the root URL. | Root URL, right port, no sub-path. |
| Test connection: unexpected answer — redirect or HTML | Proxy redirecting http → https, or serving a portal. | Final URL. |
| The newest punch time and HumanR's clock differ by whole hours | Zone mismatch. | Align BioTime's server zone with HumanR's time zone before enabling. |
| Run ok, 0 new, everything “already stored” | Normal after the first run — the lookback re-reads days already stored. | Nothing. |
| Run ok, 0 fetched | No punches in the window, or the API user's areas exclude these terminals. | Compare with BioTime's own transaction list for the same window; §3 areas. |
| N skipped — terminal disabled | Terminals registered but not yet enabled. | Enable on Attendance → Devices, then Backfill the skipped days. |
| N unmapped PIN(s) | Codes no employee holds. | Import PINs from BioTime, or Unmapped PINs. |
| N non-numeric code(s) | BioTime employee codes with letters. | Renumber in BioTime (the import then maps them) or map by hand; the sample lists the first ten. |
Punches land under BIOTIME-HQ | Transactions with no terminal serial: mobile or API punches in BioTime. | Enable that pseudo terminal if you want them; leave it disabled if not. |
| A terminal was offline for a week; its punches are missing | Offline longer than the lookback. | Backfill the week; raise the lookback for that site. |
| “the stored password cannot be read on this instance” | Different Data Protection key ring: database copied from another environment, or a rotated key. | Re-enter the password. |
| failed — more than 250,000 rows in one run | A huge window (long outage, big site). | Backfill in pieces; the cursor will take over after. |
| failed — more than 5,000 pages in one day | BioTime is not honouring page_size. | Check the BioTime version and proxy; lower the page size. |
| Badge failing for days, nobody noticed | Health watchdog not being watched. | Put /ops/health in your monitoring; the worker tick fails whenever a source fails. |
| Sync now is greyed out | Running, queued, or the password is unreadable. | Wait, or re-enter the password. |
| “BioTime sync” link missing for an admin | Flag off, or the role lacks devices.manage. | §4, steps 2 and 3. |
| After a HumanR restore from backup, the pull re-stores nothing and skips a gap | The restored cursor is older or newer than the data. | Backfill the span between the backup time and now. |
12. Security notes
- The password is sealed at rest with ASP.NET Data Protection (the instance's key ring), never shown after entry, never logged, never written to the audit log — the audit entry says “password changed: yes/no”. An instance with a different key ring cannot read it and says so (§6).
- The API token lives for one run and is not stored. A rejected token is refreshed once.
- No redirects are followed. A redirected login would hand the credentials to whatever host the redirect names. Hence the final URL, and http/https only.
- Outbound only, from the HumanR server to BioTime. Nothing opens towards HumanR.
- Read-only user. HumanR only ever issues GETs after login. A view-only role is enough and is what the guide asks for.
- BioTime's employee device-password field is never read; the import shows names, codes and departments only.
- Who may do this:
devices.manage, the permission for administering terminals. Every action is a signed form post; adding, changing and removing a source and applying a PIN import are audited. - Dark means dark. With the flag off the worker is not registered and no stored credential is used.
- Prefer HTTPS to BioTime where the server supports it; on a flat internal network plain HTTP is what most BioTime installs offer, and the credential exposure is then the same as the BioTime web console's own.
13. API reference (what HumanR calls)
All calls are relative to the source's URL, Accept: application/json, 30 s timeout, no redirects.
| Call | Purpose | What is read |
|---|---|---|
POST /api-token-auth/ {"username","password"} | Login, BioTime 8.x | token → header Authorization: Token <token> |
POST /jwt-api-token-auth/ (same body) | Login, BioTime 9.x — tried when the first answers 404/405 | token → header Authorization: JWT <token> |
GET /iclock/api/terminals/?page=&page_size=500 | Test connection; terminal names | sn, alias |
GET /iclock/api/transactions/?start_time=YYYY-MM-DD HH:MM:SS&end_time=…&page=&page_size=&limit=&ordering=punch_time,id | The punches, one day-slice at a time | id, emp_code, punch_time, punch_state, verify_type, terminal_sn, terminal_alias |
GET /personnel/api/employees/?page=&page_size=500 | Import PINs (cap 20,000) | emp_code, first_name, last_name, department |
Envelopes. Both list shapes BioTime has used are accepted:
{"count", "next", "previous", "data": […]} (8.x) and
{"count", "next", "previous", "results": […]}. Paging follows the page
number in next only — the host in next is ignored — and stops when
next is null, a page is empty, or the page number does not advance.
page_size and limit are both sent because versions differ on which they
honour.
Field handling. emp_code and punch_state are accepted as
string or number. punch_time is read to the second (YYYY-MM-DD HH:MM:SS
or ISO T) and stored unchanged as the punch's time — no zone conversion, the same
rule as a direct push. punch_state maps to the punch Status (0 in, 1 out, …) and
verify_type to its VerifyMode, both as BioTime numbers them. An employee payload's
device password field is not modelled.
Errors. 400/401 at login → credentials rejected; 403 anywhere → API not licensed (or role); 401 after a fresh re-login → not authorised; connection errors and timeouts → unreachable; 3xx, HTML or malformed JSON → unexpected answer (body quoted up to 500 characters); 5xx → BioTime error.
14. Terminals seen with BioTime so far
| Model | Notes |
|---|---|
| IN01-A / ID | Reported the best of the four in the field — fast face + card, stable ADMS. |
| K40 / ID | Fingerprint + card; fine for small gates. |
| K40 Pro | As K40 with a larger user capacity. |
| MB560-VL | Face + fingerprint; needs decent lighting at the mount point. |
All four stay configured exactly as they are — pointed at BioTime.
15. Rollout checklist
- API module confirmed on the BioTime licence (§2).
- Read-only BioTime user created;
curllogin check passes from the HumanR server (§2, §3). - Firewall rule HumanR server → BioTime host:port, outbound.
- BioTime server zone = HumanR's time zone; terminals in step with BioTime.
- HumanR deployed,
EnableBioTimeSyncflipped for this install,devices.managegranted (§4). - Source added, Enabled off; Test connection green; newest punch time within minutes of HumanR's clock (§5, §6).
- Sync now; terminals appear disabled on Attendance → Devices; enable the ones that count (§7).
- Backfill from the go-live (or history) date, ≤ 62 days per request (§7).
- Import PINs from BioTime; conflicts and non-numeric codes resolved (§8).
- Source Enabled; badge ok after the next poll; a known employee's punches match the terminal (§7, §10).
/ops/healthin your monitoring; whoever owns the BioTime password knows to re-enter it in HumanR when it rotates (§10, §12).