← CrewQR

The CrewQR API

Read your company's people, jobs and hours from another system, and be told when something happens. Version 1.

It is read-only, and that is a decision rather than a first instalment. Every hour recorded in CrewQR goes through one piece of code that enforces the meal and overtime rules, freezes the pay rate onto the shift, and refuses a punch that carries a time from the device instead of the server. A second way into those tables would be a second place for all of that to be got right, and the first version of it would be the one that was not. If you need to push time in, write to us — the answer is a purpose-built endpoint with the same guarantees, not an open door.

Getting a key

An administrator makes one in Settings → API keys. Pick a name that says whose integration it is, tick what it may read, and copy the key when it appears — it is shown once and we keep only a hash of it, so nobody here can read it back to you. Lost keys are revoked and replaced, not recovered.

A key is not a person. It appears in your activity log as itself, so a script pulling the export does not show up as whoever created it.

Rotating a key

There is no rotate button, on purpose. A key rotated in place needs a window where two secrets both work, and the window is the part everyone gets wrong — too short and the integration breaks overnight, too long and a leaked key lives for a fortnight. Instead: make a second key, move the integration onto it, revoke the first. No window, nothing to tune, and every step is visible on the screen while it happens.

Scopes

ScopeWhat it reads
read:workersNames, trades, employee numbers, whether somebody is active. Not pay.
read:projectsJobs, addresses, status, and who is crewed to them.
read:timeShifts: clock in and out, job segments, meal breaks.
read:payPay rates and burden. Grant this only to payroll and accounting.
read:exportThe full company export as a zip — every table and every photograph.

read:pay is additive rather than a resource of its own: without it, workers and shifts still answer, and the pay fields are absent from the response rather than null. An absent field is something your code notices; a null is something it writes into a payroll file as zero.

Calling it

curl -H "Authorization: Bearer cqr_xxxxxxxx_..." \
  "https://yourcompany.crewqr.com/api/v1.php/whoami"

Your base URL is your own company address with /api/v1.php on the end. Everything is a GET; a POST gets a 405 that says so.

Yes, the path has .php in it. A prettier URL would need a rewrite rule in web-server configuration that is not part of the application, and we are not willing to have a published contract depend on a file that our deploy cannot check. The version is in the path where it belongs.

Resources

PathScopeNotes
/whoaminoneWhat this key is and what it holds. Start here.
/workersread:workers?active=0 for people who have left.
/projectsread:projects?status=active.
/shiftsread:time?from=&to=&worker_id=&project_id=&approved=1.
/exportread:exportReturns a zip, not JSON.

Paging

?page=1&per=100, and per is capped at 500. Every list answers with page, per and total beside the rows, so you can tell whether you have them all without a second call.

Times

Every instant is sent twice — epoch milliseconds and an ISO-8601 string:

"clock_in": { "ms": 1756400400000, "iso": "2026-08-28T07:00:00-07:00" }

Milliseconds alone would force you to know the company's timezone before you could print a time, and getting that wrong moves a night shift onto the wrong day.

Shifts carry their segments

The job somebody worked on is on the segment, not on the shift, because people move between jobs during a day. If you are costing labour to a job, read the segments; the shift alone will put every hour wherever the day started. ?project_id= returns whole shifts that touched that job — half a shift is not a record of anything.

Rate limits

240 calls a minute per key, and 3 a minute for /export, which builds a zip of everything you own. Over the limit you get 429 with a Retry-After header and a sentence saying which limit you met and for how long.

Errors

Every failure is JSON with ok: false and an error that names the thing to fix.

CodeMeans
400A parameter is wrong — a date that is not a date.
401No key, or a key that has been revoked.
403The key is fine and does not hold the scope. The response lists what it does hold.
404No such resource in this version.
429Over a rate limit. Wait for Retry-After seconds.

Webhooks

Rather than polling, give us an address and we will post to it. Set them up in Settings → Webhooks.

The address must be https only, and reachable from the public internet. We refuse plain http, because a signed delivery over http is signed and readable; and we refuse anything that resolves inside a private network, because a URL you choose and our server fetches is otherwise a way to make our machine read its own internals for you.

Delivery is within five minutes, not instantly. A punch is a wage record, and nothing a crew does is allowed to wait on a third party's server answering — so the event is queued and a scheduled job delivers it. If that is too slow for what you are building, poll /shifts instead; it is honest about being a poll.

Events

EventWhen
punch.recordedSomebody clocked in, out, or moved to another job. Meals and rest attestations are not punches and do not fire this.
shift.approvedA day became payable. Fired on the transition, so an edit to an already-approved shift does not send it again.
timeoff.decidedA request was approved or declined.
expense.decidedA receipt was approved or declined. Carries the amount.

The body

{
  "event": "shift.approved",
  "at": 1756400400000,
  "data": {
    "shift_id": "s_9f3c…",
    "worker_id": "w_41b2…",
    "work_date": "2026-08-28",
    "approved_by": "w_0c81…"
  }
}

Checking the signature

Every delivery carries X-CrewQR-Signature: t=<ms>,v1=<hex>. The hex is an HMAC-SHA256 over the string "<t>.<raw body>", keyed with the signing secret shown when you created the webhook.

import hmac, hashlib, time

def verify(secret, header, body):          # body is BYTES, not parsed JSON
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, got = int(parts["t"]), parts["v1"]
    if abs(time.time() * 1000 - t) > 300_000:
        return False                       # older than five minutes: a replay
    want = hmac.new(secret.encode(),
                    f"{t}.".encode() + body,
                    hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, got)  # constant time, not ==

Sign the raw bytes you received, not a re-serialised copy — key order and spacing will differ and the signature will not match. Compare in constant time. Reject anything older than five minutes; the timestamp is inside the signature so a captured delivery cannot be replayed next week.

Retries

Answer with any 2xx. Anything else, or a timeout, and we try again after 1 minute, 5, 30, 2 hours and 6 hours — six attempts over about eight hours — and then give up. We do not follow redirects. Your endpoint should be idempotent: a delivery you have already handled will arrive again if your 200 did not reach us, and X-CrewQR-Delivery carries an id you can deduplicate on.

Settings shows the last result and how many deliveries are waiting for each webhook, so an endpoint that has been down since Thursday is visible without asking us.

What this version does not do

Something here wrong, missing, or not working the way it says? hello@crewqr.com. This page is meant to be enough on its own; every email about it is a defect in it.