Code examples

Copy-paste starting points in curl, PHP, Python and Node.js: test a key, page through a list, pull a pay week for payroll, sync workers, send clocks from another terminal and check a webhook signature.

Set-up

Every example reads the base URL and key from the environment. Keep keys out of source code.

export TC_BASE="https://tempclock.com/api/v1"   # your TempClock address + /api/v1
export TC_KEY="tc_live_..."                       # Setup > API keys

Test a key

curl

curl -s "$TC_BASE/me.php" -H "Authorization: Bearer $TC_KEY"

PHP

<?php
$ch = curl_init(getenv('TC_BASE') . '/me.php');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('TC_KEY')],
]);
$me = json_decode(curl_exec($ch), true);
echo $me['data']['client_name'], ': ', implode(', ', $me['data']['effective_scopes']), "\n";

A small client with retries

One function that sends the key, retries a 429 after Retry-After and a 5xx with backoff, and raises everything else with the error_code and request id.

Python

import os, time, uuid, requests

BASE, KEY = os.environ["TC_BASE"], os.environ["TC_KEY"]

class TempClockError(Exception):
    def __init__(self, status, body, request_id):
        self.status, self.code, self.request_id = status, body.get("error_code"), request_id
        super().__init__(f"{status} {self.code}: {body.get('message')} (request {request_id})")

def tc(method, path, params=None, body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {KEY}"}
    if method in ("POST", "PUT"):
        headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())   # the same key on every retry
    for attempt in range(5):
        r = requests.request(method, f"{BASE}/{path}", params=params, json=body, headers=headers, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "60"))); continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt); continue
        j = r.json()
        if r.status_code >= 400:
            raise TempClockError(r.status_code, j, r.headers.get("X-Request-Id"))
        return j
    raise TempClockError(r.status_code, r.json(), r.headers.get("X-Request-Id"))

Page through a list

Python

def all_rows(path, **params):
    offset = 0
    while True:
        page = tc("GET", path, params={**params, "limit": 100, "offset": offset})
        yield from page["data"]
        if not page["pagination"]["has_more"]:
            return
        offset += page["pagination"]["limit"]

for w in all_rows("workers.php", active="true"):
    print(w["employee_id"], w["name"], w["department_id"])

Pull a pay week for payroll

The payroll lines are the same shifts and totals as the Sage 50 Payroll file. The safe moment to pull is when the week is locked: listen for pay_week.locked, or check /pay-weeks.php. Key: payroll:read.

  1. Is the week final? GET /pay-weeks.php?week_start=YYYY-MM-DD. Wait until status is locked or exported. Until then exceptions_open says how many shifts still stop it closing.
  2. Pull the lines. GET /payroll.php?week=YYYY-MM-DD&limit=200&adjustments=1, paging with offset. Import pay_elements: one line per Sage payment reference, rate, department and cost centre.
  3. Pay late clocks. adjustments lists clocks that landed in an earlier locked week and are due in this one (state: due, adjust_week).
  4. Reconcile. batches on the pay week lists the Sage files made for it; GET /export-batches.php?id= gives the shifts each one paid.

Python

def pull_pay_week(any_date):
    week = tc("GET", "pay-weeks.php", params={"week_start": any_date})["data"][0]
    if week["status"] not in ("locked", "exported"):
        raise RuntimeError(f"Week of {week['week_start']} is {week['status']}: "
                           f"{week['exceptions_open']} shifts still to sort out")
    lines, adjustments, offset = [], [], 0
    while True:
        page = tc("GET", "payroll.php", params={"week": week["week_start"], "limit": 200,
                                                 "offset": offset, "adjustments": 1})
        lines += page["data"]
        adjustments = page.get("adjustments", adjustments)
        if not page["pagination"]["has_more"]:
            break
        offset += 200
    for line in lines:
        for el in line["pay_elements"]:
            import_pay_line(line["employee_id"], el["sage_ref"], el["hours"], el["pay_rate"],
                            el["pay"], el["department"], el["cost_centre"])
    for a in adjustments:
        if a["state"] == "due" and a["adjust_week"] == week["week_start"]:
            flag_late_clock(a["employee_id"], a["clock_in"], a["paid_minutes"])
    return week["batches"]          # the Sage files TempClock made for this week
Is the pay week final, and what was exported? 200

Key used: a key with payroll:read only (pay figures, no charges).

Request

curl -s "$TC_BASE/pay-weeks.php?week_start=2026-09-25" \
  -H "Authorization: Bearer $TC_KEY"

Real reply (local test copy, fake demo data)

HTTP 200

{
  "data": [
    {
      "week_start": "2026-09-21",
      "week_end": "2026-09-27",
      "status": "exported",
      "ended": true,
      "locked": true,
      "locked_at": "2026-10-01 07:33:35",
      "locked_at_utc": "2026-10-01T06:33:35Z",
      "locked_by": null,
      "lock_id": 19,
      "frozen": false,
      "shifts": 107,
      "shifts_not_approved": 29,
      "open_clock_outs": 0,
      "exceptions_open": 0,
      "late_adjustments_due": 0,
      "late_adjustments_paid": 0,
      "late_clocks_in_week": 1,
      "batches": [
        {
          "id": 8,
          "kind": "sage",
          "format": "sage50",
          "client_id": 21,
          "period_start": "2026-09-21",
          "period_end": "2026-09-27",
          "entry_count": 2,
          "line_count": 4,
          "file_name": "sage50_payroll_2026-09-21.csv",
          "file_sha256": "2393f9791223b6c74c664b8ad9549911439b7e9f10057d5c5ae9b9807b36f322",
          "reason": null,
          "created_at": "2026-10-02 07:33:35",
          "created_at_utc": "2026-10-02T06:33:35Z",
          "stamps_entries": true,
          "total_pay": 241.5
        }
      ]
    }
  ],
  "meta": {
    "period_from": "2026-09-21",
    "period_to": "2026-09-27",
    "pay_week_start_day": 1,
    "approval_rule": "agency_approved"
  }
}
With entry ids and late adjustments, charges included 200

Key used: a key holding every scope.

Request

curl -s "$TC_BASE/payroll.php?week=2026-09-25&include_entries=1&adjustments=1&limit=1" \
  -H "Authorization: Bearer $TC_KEY"

Real reply (local test copy, fake demo data)

HTTP 200

{
  "data": [
    {
      "worker_id": 351,
      "employee_id": "AX025",
      "worker_name": "Aisha Rahman",
      "pay_week_start": "2026-09-21",
      "pay_week_end": "2026-09-27",
      "shifts": 1,
      "paid_minutes": 168,
      "paid_hours": 2.8,
      "gross_pay": 37.8,
      "charge_total": 52.92,
      "margin": 15.12,
      "base_pay_rate": 13.5,
      "department": "WH-OPS",
      "cost_centre": "WH-OPS",
      "elements": {
        "regular": {
          "minutes": 168,
          "hours": 2.8,
          "pay": 37.8,
          "pay_rate": 13.5,
          "charge": 52.92,
          "charge_rate": 18.9
        },
        "overtime": {
          "minutes": 0,
          "hours": 0,
          "pay": 0,
          "pay_rate": null,
          "charge": 0,
          "charge_rate": null
        },
        "weekend": {
          "minutes": 0,
          "hours": 0,
          "pay": 0,
          "pay_rate": null,
          "charge": 0,
          "charge_rate": null
        },
        "bank_holiday": {
          "minutes": 0,
          "hours": 0,
          "pay": 0,
          "pay_rate": null,
          "charge": 0,
          "charge_rate": null
        },
        "night": {
          "minutes": 0,
          "hours": 0,
          "pay": 0,
          "pay_rate": null,
          "charge": 0,
          "charge_rate": null
        }
      },
      "pay_elements": [
        {
          "element": "regular",
          "label": "Basic",
          "sage_ref": "Basic",
          "minutes": 168,
          "hours": 2.8,
          "pay_rate": 13.5,
          "pay": 37.8,
          "department": "WH-OPS",
          "cost_centre": "WH-OPS",
          "charge": 52.92,
          "entry_ids": [
            36685
          ]
        }
      ],
      "source": "live",
      "flags": [],
      "entry_ids": [
        36685
  ... (64 more lines)

Sync workers with updated_since

A nightly job that copies new and changed workers into another system. Store the time each run started and send it next time; de-duplicate on id. Key: workers:read (add pay:read only if the other system needs rates).

Node.js

// Node.js 18+ (global fetch)
const BASE = process.env.TC_BASE, KEY = process.env.TC_KEY;

async function tc(path, params = {}) {
  const url = new URL(`${BASE}/${path}`);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
  for (;;) {
    const r = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
    if (r.status === 429) { await new Promise(s => setTimeout(s, 1000 * (r.headers.get('Retry-After') || 60))); continue; }
    const j = await r.json();
    if (!r.ok) throw new Error(`${r.status} ${j.error_code}: ${j.message} (request ${r.headers.get('X-Request-Id')})`);
    return j;
  }
}

async function syncWorkers(lastRunStartedAt) {           // e.g. '2026-10-05T23:00:00Z'
  const startedAt = new Date(Date.now() - 60_000).toISOString();  // a minute early, from our own clock
  let offset = 0, page;
  do {
    page = await tc('workers.php', { updated_since: lastRunStartedAt, limit: 100, offset });
    for (const w of page.data) {
      await upsertWorker(w.id, {                         // keyed on id: a repeat is harmless
        employeeId: w.employee_id, name: w.name, active: w.active, status: w.status,
        siteId: w.location_id, departmentId: w.department_id, costCodeId: w.cost_code_id,
      });
    }
    offset += page.pagination.limit;
  } while (page.pagination.has_more);
  return startedAt;                                      // store it for the next run
}
Workers changed since a time (nightly sync) 200

Key used: a key holding every scope.

Request

curl -s "$TC_BASE/workers.php?updated_since=2026-01-01T00%3A00%3A00Z&limit=1" \
  -H "Authorization: Bearer $TC_KEY"

Real reply (local test copy, fake demo data)

HTTP 200

{
  "data": [
    {
      "id": 351,
      "name": "Aisha Rahman",
      "employee_id": "AX025",
      "location_id": 39,
      "location_name": "Birmingham Office",
      "department_id": 42,
      "department_name": "Customer Service",
      "cost_code_id": null,
      "cost_code": null,
      "email": null,
      "phone": null,
      "job_role": "Customer Service Agent",
      "hourly_rate": 12.7,
      "charge_rate": 20.07,
      "max_hours_per_week": 48,
      "status": "active",
      "active": true,
      "dormant": false,
      "dormant_since": null,
      "dormant_since_utc": null,
      "start_date": "2026-03-18",
      "end_date": null,
      "leave_reason": null,
      "preferred_language": null,
      "has_face": false,
      "has_pin": true,
      "created_at": "2026-09-30 10:58:11",
      "created_at_utc": "2026-09-30T09:58:11Z",
      "updated_at": "2026-09-30 10:58:11",
      "updated_at_utc": "2026-09-30T09:58:11Z"
    }
  ],
  "pagination": {
    "total": 25,
    "limit": 1,
    "offset": 0,
    "has_more": true
  }
}

Send clocks from another terminal

A turnstile, door reader or another time clock sends each badge read as a clock. Send the time it really happened in occurred_at (up to 15 minutes back) and an Idempotency-Key made from the read itself, so a retry after a network drop can never clock twice. Key: clock:write only.

Python

import hashlib

def send_badge_read(read):                       # read = {"badge": "AX014", "direction": "in", "at": "2026-10-06T05:58:41Z", "terminal": "GATE-2"}
    key = hashlib.sha256(f"{read['terminal']}|{read['badge']}|{read['at']}".encode()).hexdigest()[:40]
    try:
        r = tc("POST", "clock.php", idempotency_key=key, body={
            "employee_id": read["badge"],
            "action": read["direction"],          # "in" or "out"
            "location_id": GATE_SITE_ID,
            "occurred_at": read["at"],
        })
        return r["data"]["entry_id"]
    except TempClockError as e:
        if e.code in ("already_clocked_in", "no_open_clock_in"):
            return None                           # a double read: nothing to do
        if e.code in ("worker_pending", "worker_inactive", "not_found"):
            alert_supervisor(read, e)             # someone who should not be on site
            return None
        raise
Clock in by employee ID, with the time it really happened 201

Key used: a key holding every scope.

Request

curl -s -X POST "$TC_BASE/clock.php" \
  -H "Authorization: Bearer $TC_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: docs-clock-77e88" \
  -d '{"employee_id":"DOCS-77E88","action":"in","location_id":37,"occurred_at":"2026-10-02T06:19:35Z"}'

Real reply (local test copy, fake demo data)

HTTP 201

{
  "data": {
    "entry_id": 40785,
    "worker_id": 409,
    "employee_id": "DOCS-77E88",
    "worker_name": "Jordan Example",
    "action": "clock_in",
    "clock_in": "2026-10-02 07:19:35",
    "clock_in_utc": "2026-10-02T06:19:35Z",
    "location_id": 37,
    "scheduled_shift_id": null,
    "risk_flags": [
      "api_backdated"
    ],
    "backdated": true
  },
  "message": "Jordan Example clocked in at 07:19"
}
Clock out (the note is added after the clock-in note) 200

Key used: a key holding every scope.

Request

curl -s -X POST "$TC_BASE/clock.php" \
  -H "Authorization: Bearer $TC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"worker_id":409,"action":"out","notes":"Left by the main gate turnstile"}'

Real reply (local test copy, fake demo data)

HTTP 200

{
  "data": {
    "entry_id": 40785,
    "worker_id": 409,
    "employee_id": "DOCS-77E88",
    "worker_name": "Jordan Example",
    "action": "clock_out",
    "clock_in": "2026-10-02 07:19:35",
    "clock_out": "2026-10-02 07:33:36",
    "clock_in_utc": "2026-10-02T06:19:35Z",
    "clock_out_utc": "2026-10-02T06:33:36Z",
    "total_minutes": 14,
    "hours": 0.23,
    "location_id": 37,
    "scheduled_shift_id": null,
    "risk_flags": [
      "api_backdated"
    ],
    "backdated": false
  },
  "message": "Jordan Example clocked out. Duration: 0h 14m"
}

Book and cancel

PHP

<?php
function tc(string $method, string $path, ?array $body = null): array {
    $ch = curl_init(getenv('TC_BASE') . '/' . $path);
    $h = ['Authorization: Bearer ' . getenv('TC_KEY'), 'Content-Type: application/json',
          'Idempotency-Key: ' . bin2hex(random_bytes(16))];
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $j = json_decode((string)curl_exec($ch), true);
    if (!empty($j['error'])) throw new RuntimeException($j['error_code'] . ': ' . $j['message']);
    return $j;
}

$siteId = 37; $workerId = 377;      // ids from your account (GET /locations.php, /workers.php)

// A requirement for 3 pickers, then one worker booked onto it.
$req = tc('POST', 'demands.php', ['location_id' => $siteId, 'shift_date' => '2026-10-28',
          'start_time' => '06:00', 'end_time' => '14:00', 'headcount' => 3, 'job_role' => 'Picker']);
try {
    $shift = tc('POST', 'shifts.php', ['worker_id' => $workerId, 'demand_id' => $req['data']['id'], 'status' => 'published']);
} catch (RuntimeException $e) {
    // not_eligible: the worker cannot take it (block_codes says why, for example overlap or absence)
    exit($e->getMessage() . "\n");
}
// The client cancels: the slot shows as open again and the cancelled-booking pay policy applies.
tc('POST', 'shifts.php?id=' . $shift['data']['id'] . '&action=cancel',
   ['status' => 'client_cancelled', 'cancel_reason' => 'Client reduced headcount']);

Receive a webhook

Check the signature over the raw body in constant time, answer 200 quickly, and ignore a delivery id you have already handled. Full details and PHP and Python versions are on Webhooks.

Node.js

const crypto = require('crypto');
function verifyTempClock(rawBody, signatureHeader, secret) {
  const want = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'));
  const sent = Buffer.from(signatureHeader || '');
  return want.length === sent.length && crypto.timingSafeEqual(want, sent);
}