Developers

A REST API and signed webhooks, documented in full

Read your people, attendance, leave and performance data with a scoped bearer token; push punches in from whatever time system you already run; and get an HMAC-signed callback when payroll closes or a permit is about to expire. Everything on this page is the surface as it ships today — where something does not exist yet, it says so instead of describing a roadmap.

On this page

Two things to know before you start

The API is a per-install switch, and it is off until you ask

While the module is off, every /api/v1route answers 404 rather than 401 — there is nothing to discover by scanning, and no credential surface on an install that never asked for one. Ask us to turn it on for your instance, then grant someone the API-tokens permission so they can issue credentials.

v1 is read-mostly: attendance punches are the only write

You can read employees, reference data, attendance days and leave, and you can push punches in. You cannot yet create an employee or file leave through the API. Employee writes run through validation and leave runs through the approval engine; exposing either without that would be worse than not exposing it, so they are deliberately absent rather than half-built.

Every path below is relative to your own install — https://hr.yourcompany.com/api/v1/… — because HumanR runs on your infrastructure or in our cloud under your own hostname. There is no shared multi-tenant endpoint to point at.

Your first request
curl https://hr.yourcompany.com/api/v1/employees?limit=2 \
  -H "Authorization: Bearer hr_live_9c1f4b2ae7d05836…"

Authentication

Every request carries a bearer token in the Authorization header. There is no OAuth dance, no refresh cycle and no session — a long-lived scoped credential is the right shape for a server-to-server integration, and the wrong shape for anything running in a browser.

Header
Authorization: Bearer hr_live_9c1f4b2ae7d05836…
Shown once, stored as a hash
A token is 80 characters beginning hr_live_. It is displayed exactly once at issue; only its SHA-256 hash and its first few characters are stored. Nobody — us included — can read it back out of the database, so a lost token is revoked and re-issued rather than recovered.
A token cannot outgrow its issuer
The issue screen offers only scopes the admin already holds, and anything outside the catalogue below is dropped rather than granted. A token can also be pinned to one company and given an expiry date. Up to 25 tokens per install — enough for real integrations, few enough that the list stays something a person reviews.
Scopes decide reads the same way permissions decide clicks
A token’s scopes become permission claims on the request, so the same gates and the same sensitive-field masking that govern the UI govern the API. There is no separate API authorization path to get out of step.

The grantable scopes

employees.view

Read employees

List and read employee records. Personal and pay fields stay masked unless their own scope is also granted.

refdata.view

Read reference data

List companies, departments, designations and worksites — the ids you resolve foreign keys against.

leave.view

Read leave

List leave requests and their status.

attendance.view

Read attendance

Read the rolled-up attendance days.

attendance.manage

Ingest attendance punches

Push punches in from an external time system. The only write in v1.

performance.view

Read performance

List review cycles, the reviews in them and the goal register. Ratings stay masked unless their own scope is also granted.

sensitive.performance

See ratings

Unmask the scores and the rating labels on performance reads. Only meaningful alongside performance.view.

sensitive.personal

See personal fields

Unmask date of birth and addresses on employee reads. Only meaningful alongside employees.view.

sensitive.pay

See pay fields

Unmask the bank account on employee reads. Only meaningful alongside employees.view.

Conventions

The same shapes everywhere, so you write the plumbing once.

Wire format

JSON, camelCase keys, enums as their string names rather than integers, and nulls omitted entirely. A field you do not see is either empty or outside your token’s scope.

Dates

Calendar dates are YYYY-MM-DD; instants are ISO 8601 UTC. Send updatedSince as an instant.

Paging

List endpoints return { data, total, limit, offset }. Default page size 50, maximum 200 — ask for more and you are capped rather than rejected, so a naive client still works. Reference lists are returned whole; those tables are small by nature.

Incremental sync

Employees and leave accept updatedSince. Store the high-water mark from your last pass and you fetch changes, not the world.

Paged envelope
{
  "data": [ … ],
  "total": 613,
  "limit": 50,
  "offset": 0
}

Endpoints

Nine routes in v1. Each one expands to its parameters, an example response and the behaviour worth knowing before you rely on it.

GET/api/v1/employeesemployees.viewThe people directory, one page at a time.

Query parameters

companyIdint
Restrict to one company. A company-pinned token cannot widen past its own boundary with this.
statusstring
Employee status name, e.g. Active.
updatedSincedate-time
Only rows changed at or after this UTC instant — the incremental-sync hook.
limitint
Page size. Default 50, maximum 200; anything larger is capped rather than rejected.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "id": 1042,
      "empNo": "E-1042",
      "fullName": "Aishath Nazla",
      "gender": "Female",
      "nationality": "Maldivian",
      "email": "nazla@example.com",
      "status": "Active",
      "joinedDate": "2023-04-01",
      "companyId": 1,
      "company": "Island Resorts Ltd",
      "departmentId": 4,
      "department": "Housekeeping",
      "designationId": 11,
      "designation": "Room Attendant",
      "workSiteId": 2,
      "workSite": "North Island",
      "devicePin": 1042,
      "updatedAt": "2026-08-07T09:14:22Z"
    }
  ],
  "total": 613,
  "limit": 50,
  "offset": 0
}
  • Ordered by staff number, so paging is stable while you walk it.
  • dateOfBirth, permanentAddress and currentAddress appear only with sensitive.personal; bankAccount only with sensitive.pay. Without those scopes the fields are absent from the JSON entirely rather than blanked.
GET/api/v1/employees/{id}employees.viewOne employee by id.
200 Response
{
  "id": 1042,
  "empNo": "E-1042",
  "fullName": "Aishath Nazla",
  "status": "Active",
  "updatedAt": "2026-08-07T09:14:22Z"
}
  • 404 with the standard error envelope when the id does not exist — or when it exists in a company your token is not pinned to. The two are deliberately indistinguishable.
GET/api/v1/attendance/daysattendance.viewThe rolled-up attendance day rows — one per employee per day.

Query parameters

employeeIdint
Restrict to one employee.
fromdate
Inclusive start of the date range.
todate
Inclusive end of the date range.
limitint
Page size. Default 50, maximum 200.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "employeeId": 1042,
      "empNo": "E-1042",
      "employeeName": "Aishath Nazla",
      "date": "2026-08-06",
      "status": "Present",
      "firstIn": "2026-08-06T08:02:11Z",
      "lastOut": "2026-08-06T17:31:04Z",
      "punchCount": 4
    }
  ],
  "total": 18390,
  "limit": 50,
  "offset": 0
}
  • These are computed day rows, not raw punches — the same figures the attendance register and payroll read.
  • firstIn and lastOut are absent on a day with no punches.
  • Newest first, then by employee.
POST/api/v1/attendance/punchesattendance.managePush a batch of punches in from an external time system. The only write in v1.
Request
curl -X POST https://hr.yourcompany.com/api/v1/attendance/punches \
  -H "Authorization: Bearer hr_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "punches": [
      { "empNo": "E-1042", "punchedAt": "2026-08-06T08:02:11Z" },
      { "devicePin": 1043, "punchedAt": "2026-08-06T08:04:57Z", "status": 0, "verifyMode": 1 },
      { "empNo": "GHOST-9",  "punchedAt": "2026-08-06T08:06:00Z" }
    ]
  }'
200 Response
{
  "accepted": 2,
  "duplicates": 0,
  "unresolved": [
    {
      "index": 2,
      "empNo": "GHOST-9",
      "punchedAt": "2026-08-06T08:06:00Z"
    }
  ]
}
  • Up to 1000 punches per call. A larger batch is a 400, not a truncation.
  • The employee is resolved by empNo first, then devicePin, then employeeId — send whichever key your system actually holds.
  • Idempotent on (employee, punchedAt): re-post the same batch and everything comes back under duplicates having changed nothing. Retry freely.
  • A mixed batch succeeds. Resolvable punches land; the rest come back under unresolved with their index so you can correct and re-send just those.
  • status and verifyMode mirror the raw device codes (check-in/out, fingerprint/face/card) and default to 0 when your system has no equivalent.
  • Ingested punches are attributed to the token's name in the audit trail, so a bad batch traces back to one credential.
GET/api/v1/leave-requestsleave.viewLeave requests and their lifecycle.

Query parameters

employeeIdint
Restrict to one employee.
statusstring
Leave status name, e.g. Approved.
updatedSincedate-time
Only rows changed at or after this UTC instant.
limitint
Page size. Default 50, maximum 200.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "id": 8821,
      "employeeId": 1042,
      "empNo": "E-1042",
      "employeeName": "Aishath Nazla",
      "type": "AL",
      "typeName": "ANNUAL LEAVE",
      "status": "Approved",
      "departureDate": "2026-09-02",
      "returnDate": "2026-09-16",
      "days": 14,
      "updatedAt": "2026-08-01T04:22:10Z"
    }
  ],
  "total": 271,
  "limit": 50,
  "offset": 0
}
  • days is the effective charge, so you do not have to know the registry's applicable-versus-recorded fallback to bill leave correctly.
  • type is the customer's own leave code and typeName its name — the catalogue is theirs to extend, so treat the set as open rather than switching on a fixed list.
  • actualReturnDate appears once someone is back and it differed from the plan.
  • Newest departure first.
GET/api/v1/performance/cyclesperformance.viewReview cycles, with how far each has got.

Query parameters

statusstring
Cycle status name, e.g. Open, InCalibration, Closed.
kindstring
Cycle kind, e.g. Annual, Probation.
updatedSincedate-time
Only rows changed at or after this UTC instant.
limitint
Page size. Default 50, maximum 200.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "id": 4,
      "name": "Annual review 2026",
      "kind": "Annual",
      "status": "Open",
      "periodStart": "2026-01-01",
      "periodEnd": "2026-12-31",
      "selfDueOn": "2026-08-03",
      "managerDueOn": "2026-08-24",
      "closeOn": "2026-09-09",
      "companyId": 1,
      "company": "Coral Sands Resort",
      "participants": 214,
      "finalized": 138,
      "acknowledged": 96,
      "launchedAt": "2026-07-20T06:00:00Z",
      "updatedAt": "2026-08-10T09:14:02Z"
    }
  ],
  "total": 6,
  "limit": 50,
  "offset": 0
}
  • The counts are the progress bar: participants is everyone in scope, finalized counts anything signed off (including declined acknowledgements), acknowledged counts the ones the employee has signed.
  • A cycle with no companyId covers the whole group, so a company-pinned token still sees it — it covers that company's people too.
  • Newest period end first.
  • Every path under /api/v1/performance answers only where the performance module is switched on. Elsewhere it 404s and is absent from the API explorer.
GET/api/v1/performance/cycles/{id}performance.viewOne cycle by id.
200 Response
{
  "id": 4,
  "name": "Annual review 2026",
  "kind": "Annual",
  "status": "Open",
  "periodStart": "2026-01-01",
  "periodEnd": "2026-12-31",
  "participants": 214,
  "finalized": 138,
  "acknowledged": 96,
  "updatedAt": "2026-08-10T09:14:02Z"
}
  • A cycle outside a company-pinned token's boundary is a 404, not a 403 — the boundary does not confirm what is behind it.
GET/api/v1/performance/reviewsperformance.viewOne row per person per cycle, with the outcome.

Query parameters

cycleIdint
Restrict to one cycle.
employeeIdint
Restrict to one employee.
statusstring
Participation status name, e.g. Finalized, Acknowledged.
updatedSincedate-time
Only rows changed at or after this UTC instant.
limitint
Page size. Default 50, maximum 200.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "id": 1180,
      "reviewCycleId": 4,
      "reviewCycle": "Annual review 2026",
      "kind": "Annual",
      "employeeId": 1042,
      "empNo": "E-1042",
      "employeeName": "Aishath Nazla",
      "department": "Housekeeping",
      "designation": "Room Attendant",
      "workSite": "North Island",
      "manager": "Ibrahim Rasheed",
      "status": "Acknowledged",
      "outcome": "None",
      "finalScore": 4.00,
      "rating": "Exceeds expectations",
      "submittedAt": "2026-08-04T10:31:00Z",
      "finalizedAt": "2026-08-05T02:12:44Z",
      "acknowledgedOn": "2026-08-06T04:50:19Z",
      "acknowledgementRefused": false,
      "updatedAt": "2026-08-06T04:50:19Z"
    }
  ],
  "total": 214,
  "limit": 50,
  "offset": 0
}
  • finalScore, calibratedScore and rating appear only with sensitive.performance. Without it the fields are absent from the JSON entirely rather than blanked — the rest of the row still tells you whether the review happened.
  • The header fields are the snapshot taken when the cycle launched, not today's record: a review of somebody who has since moved department still reads as the department they were reviewed in.
  • calibratedScore is present only where a calibration meeting moved the number; the manager's original stays in finalScore.
  • Answers, comments and the evidence panel are not exposed here. They are narrative about a person and stay behind the audited screens.
  • Ordered by cycle, then by employee number.
GET/api/v1/performance/reviews/{id}performance.viewOne review by id.
200 Response
{
  "id": 1180,
  "reviewCycleId": 4,
  "reviewCycle": "Annual review 2026",
  "employeeId": 1042,
  "empNo": "E-1042",
  "employeeName": "Aishath Nazla",
  "status": "Acknowledged",
  "outcome": "None",
  "finalScore": 4.00,
  "rating": "Exceeds expectations",
  "finalizedAt": "2026-08-05T02:12:44Z",
  "updatedAt": "2026-08-06T04:50:19Z"
}
GET/api/v1/performance/goalsperformance.viewThe goal register, with progress worked out at read time.

Query parameters

employeeIdint
Restrict to one employee.
cycleIdint
Restrict to goals attached to one review cycle.
statusstring
Goal status name, e.g. Active, Achieved, Missed.
updatedSincedate-time
Only rows changed at or after this UTC instant.
limitint
Page size. Default 50, maximum 200.
offsetint
Rows to skip. Default 0.
200 Response
{
  "data": [
    {
      "id": 312,
      "employeeId": 1042,
      "empNo": "E-1042",
      "employeeName": "Aishath Nazla",
      "reviewCycleId": 4,
      "parentGoalId": 300,
      "title": "Room inspection pass rate",
      "kind": "Kpi",
      "measureType": "Percent",
      "status": "Active",
      "startValue": 82,
      "targetValue": 95,
      "unit": "%",
      "weight": 1.0,
      "startOn": "2026-01-01",
      "dueOn": "2026-12-31",
      "percent": 70.0,
      "latestValue": 91,
      "lastCheckedOn": "2026-06-01",
      "confidence": "AtRisk",
      "rolledUp": false,
      "updatedAt": "2026-06-01T07:40:11Z"
    }
  ],
  "total": 48,
  "limit": 50,
  "offset": 0
}
  • percent is derived on every read and stored nowhere: a leaf goal reads its latest check-in against start and target, a parent reads its children weighted. rolledUp tells you which rule produced the number, because "80%, rolled up from four goals" and "80%, as of 12 May" are different claims.
  • A goal nobody has checked in on reads 0 with no lastCheckedOn — which is the answer, not a gap.
  • confidence is the last thing a human said about the goal, not a computed judgement.
  • The targets come through unmasked with performance.view alone: a target of 12 audits is an objective, not a rating. The closure note is absent from the shape entirely.
  • Ordered by employee, then due date.
GET/api/v1/companiesrefdata.viewCompanies, for resolving ids.
200 Response
[
  { "id": 1, "name": "Island Resorts Ltd", "isActive": true },
  { "id": 2, "name": "Island Marine Services", "isActive": true }
]
  • Full list, not paginated — these tables are small by nature.
GET/api/v1/departmentsrefdata.viewDepartments, for resolving ids.
200 Response
[
  { "id": 4, "name": "Housekeeping", "isActive": true },
  { "id": 5, "name": "Engineering", "isActive": true }
]
GET/api/v1/designationsrefdata.viewDesignations, for resolving ids.
200 Response
[
  { "id": 11, "name": "Room Attendant", "isActive": true },
  { "id": 12, "name": "Chief Engineer", "isActive": true }
]
GET/api/v1/worksitesrefdata.viewWork sites and vessels, for resolving ids.
200 Response
[
  { "id": 2, "name": "North Island", "kind": "Site", "code": "NI", "isActive": true },
  { "id": 7, "name": "MV Fehendhoo", "kind": "Vessel", "code": "FEH", "isActive": true }
]
  • kind separates a vessel from a fixed site — the distinction the deployment tracker runs on.

Webhooks

Rather than poll for a payroll run that closes twice a month, register an endpoint and let it come to you. An admin adds the URL inside HumanR, picks the events, and gets a signing secret. Slack and Microsoft Teams incoming-webhook URLs are supported too, where the URL itself is the credential and the message arrives formatted for the channel.

The envelope

Every delivery carries the same outer shape, stamped with a payload version so a consumer can branch on it instead of guessing.

POST body
{
  "version": "2026-08-07",
  "id": "evt_3f9c1a7b4e2d48a6b0c15d7e9f42ab13",
  "event": "payroll.run.finalized",
  "occurredAt": "2026-08-09T06:31:44Z",
  "instance": "island-resorts",
  "data": {
    "runId": 91,
    "label": "August 2026 — monthly",
    "kind": "Monthly",
    "year": 2026,
    "month": 8,
    "periodStart": "2026-08-01",
    "periodEnd": "2026-08-31",
    "status": "Finalized",
    "employeeCount": 613,
    "totals": [
      { "currency": "MVR", "netPay": 4192880.55 },
      { "currency": "USD", "netPay": 38420.00 }
    ]
  }
}

Note what the payroll event does notcontain: there is no per-employee amount in it, only headcount and per-currency totals. That is a shape constraint rather than a setting — the builder has no parameter a payslip figure could travel through. Expiry and approval events carry a name, staff number, department and the item at stake, and nothing more. A webhook often ends up in a chat channel, and a chat channel is not a payslip.

Headers

X-HumanR-Event
The event key, matching the body's event field.
X-HumanR-Event-Id
Stable across retries. Deduplicate on this, not on the delivery id.
X-HumanR-Delivery
This attempt. Changes on every retry.
X-HumanR-Timestamp
Unix seconds, the same value as t= in the signature.
X-HumanR-Payload-Version
The envelope version, currently 2026-08-07.
X-HumanR-Signature
t=<unix>,v1=<hex>. Verify this before trusting the body.

Requests arrive with User-Agent: HumanR-Webhooks/1.0. Signature headers are sent on generic HTTP endpoints; Slack and Teams URLs are their own credential and carry no signature.

Verifying the signature

X-HumanR-Signature is t=<unix>,v1=<hex>. The hex is an HMAC-SHA256 over the literal string {t}.{raw body} keyed with your whsec_… secret. Three things matter: hash the raw bytes you received rather than a re-serialized object, compare in constant time, and reject a timestamp more than five minutes old so a captured delivery cannot be replayed. During a secret rotation the header carries two v1values — accept either and you can roll the secret without dropping a delivery.

Node.js
import crypto from "node:crypto";

// Sign the raw body exactly as received — parse it only after the check passes.
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.create(null);
  const digests = [];
  for (const part of header.split(",")) {
    const [k, v] = part.trim().split("=");
    if (k === "t") parts.t = v;
    else if (k === "v1") digests.push(v);   // more than one during a rotation
  }
  if (!parts.t || digests.length === 0) return false;

  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (skew > toleranceSeconds) return false;  // replay guard

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return digests.some((d) =>
    d.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(d), Buffer.from(expected)));
}
Python
import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: str, tolerance: int = 300) -> bool:
    t, digests = None, []
    for part in header.split(","):
        k, _, v = part.strip().partition("=")
        if k == "t":
            t = v
        elif k == "v1":
            digests.append(v)          # more than one during a rotation
    if not t or not digests:
        return False

    if abs(int(time.time()) - int(t)) > tolerance:   # replay guard
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()

    return any(hmac.compare_digest(d, expected) for d in digests)

Events you can subscribe to

Payroll

Fires when a run closes, and again if someone reopens it.

payroll.run.finalizedpayroll.run.reopened

Approvals

One key per request type and outcome — approval.leave-request.approved, approval.loan.rejected, and so on. Subscribe with approval.* unless you want one kind.

approval.{type}.approvedapproval.{type}.rejectedapproval.{type}.cancelled

Expiry

The nightly watch crossing a threshold — 90, 60, 30, 7 and 0 days out.

document.expiringcontract.expiringprobation.endingpermit.expiringcertifications.expiringats.offer.expiringtax.scale.review-due

Recruitment

The hiring pipeline, end to end.

ats.application.receivedats.stage.changedats.interview.scheduledats.offer.sentats.hiredats.rejected

Performance

A review being signed off, and a cycle being closed. Neither payload carries a score: a rating is as career-affecting as a disciplinary note, and a webhook lands in an inbox nobody permissioned. A consumer entitled to the number reads the reviews endpoint with sensitive.performance, where the field gate still applies.

performance.review.finalizedperformance.cycle.closed

Diagnostics

Sent by the Test button on an endpoint, so you can wire up a consumer before any real event exists.

test.ping

An endpoint subscribes with an exact key, a family wildcard like approval.* or ats.*, or * for everything.

Delivery, retries and giving up

  • Answer 2xx quickly. Queue the work and return; do not process inline and time us out.
  • Deduplicate on X-HumanR-Event-Id. It is stable across retries. The delivery id is not — it changes every attempt, which is what makes it useful in a log.
  • Retries back off on a 1-minute, 5-minute, 30-minute, 2-hour, 6-hour ladder, five attempts in all. After that the delivery is parked as dead and kept 30 days, so you can see what was missed rather than discovering the gap later.
  • 410 Gone stops us immediately and permanently — the polite way to decommission a consumer. An endpoint that fails 20 times in a row disables itself instead of hammering a dead host forever.
  • Test before you rely on it. The Test button on an endpoint sends a real, signed test.ping, so you can build and verify a consumer before the first genuine event exists.

Errors & limits

Every failure comes back in one envelope with a machine-stable code, so you branch on error.code and log error.message for the human.

Error envelope
{
  "error": {
    "code": "validation_failed",
    "message": "The request body failed validation.",
    "details": [
      { "field": "Punches[0].PunchedAt", "message": "The PunchedAt field is required." }
    ]
  }
}
unauthorized

401

No Authorization header, or a token that does not match anything live — revoked, expired, deactivated, or simply wrong.
forbidden

403

The token is valid but does not carry the scope this endpoint requires. Issue a new token; scopes are fixed at issue.
not_found

404

No record with that id, or none your token is allowed to see.
validation_failed

400

The body did not pass validation. details carries one entry per offending field, naming the field and what was wrong with it.

Rate limit

300 requests per minute per token by default, adjustable per install. Over it you get a 429 in the same error envelope. Two tokens have two independent budgets, so a chatty integration cannot starve a critical one.

Other ceilings

200 rows per page, 1000 punches per ingest call, 25 tokens and 25 webhook endpoints per install. All bounded on purpose — none of them is a number you should hit in normal operation.

Something here wrong, or missing?

This page is maintained alongside the code, not after it. If a header name, a limit or a field does not match what your client actually receives, tell us at hello@humanr.online and we will fix whichever end is wrong. Building something that needs an endpoint v1 does not have? That is worth a conversation — get in touch.

Developer questions

How do we get a token?

An admin issues one inside HumanR under API tokens, choosing its scopes, optionally pinning it to a single company and giving it an expiry. The token is displayed once at that moment and stored only as a hash afterwards — nobody, us included, can read it back out of the database. Lose it and you revoke and re-issue rather than recover.

Can a token do more than the person who created it?

No. The issue screen offers only scopes the admin already holds themselves, and anything outside the catalogue is dropped rather than granted. A leaked token's blast radius is bounded by a person you can name.

Is the API on by default?

No. It is a per-install switch that is off until someone turns it on, and while it is off every /api/v1 route answers 404 rather than 401 — there is nothing to find by scanning. Ask us to enable it on your instance, then grant the API-tokens permission to whoever will manage credentials.

Can we write employees or leave back in?

Not in v1. Attendance punches are the only write. Employee and leave writes run through validation and the approval engine respectively, and exposing them without that would be worse than not exposing them — so they are a v2 concern and deliberately absent rather than half-built. If a group deal needs the employee master flowing both ways, tell us and we will scope it.

Is there an inbound webhook?

No. There is no endpoint you register with a third party so it can notify us of its events. Data comes in through the REST API, the biometric device push, the attendance punch import, or a migration load we run for you.

What happens if our webhook consumer goes down?

Deliveries retry on a 1-minute, 5-minute, 30-minute, 2-hour, 6-hour ladder and then park as dead, kept 30 days so you can see what was missed. Answer 410 Gone and we stop immediately and permanently. An endpoint that fails 20 times in a row disables itself rather than hammering a dead host forever.

Do you publish an OpenAPI spec?

Not yet as a public file. Your own install serves an interactive explorer and the raw spec at /api/v1/docs once the module is on, generated from the same code that serves the requests and therefore unable to drift from it. A dark instance mounts no such route at all. Publishing a spec here is on the list.

Try the API against a real instance

Book a demo and we will switch the API on in your demo company, issue you a scoped token and let you point your own client at it.

No credit card. No sales call required. A real login, emailed to you.