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.
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.
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.
| Scope | What it reads |
|---|---|
read:workers | Names, trades, employee numbers, whether somebody is active. Not pay. |
read:projects | Jobs, addresses, status, and who is crewed to them. |
read:time | Shifts: clock in and out, job segments, meal breaks. |
read:pay | Pay rates and burden. Grant this only to payroll and accounting. |
read:export | The 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.
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.
| Path | Scope | Notes |
|---|---|---|
/whoami | none | What this key is and what it holds. Start here. |
/workers | read:workers | ?active=0 for people who have left. |
/projects | read:projects | ?status=active. |
/shifts | read:time | ?from=&to=&worker_id=&project_id=&approved=1. |
/export | read:export | Returns a zip, not JSON. |
?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.
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.
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.
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.
Every failure is JSON with ok: false and an error that names the thing
to fix.
| Code | Means |
|---|---|
| 400 | A parameter is wrong — a date that is not a date. |
| 401 | No key, or a key that has been revoked. |
| 403 | The key is fine and does not hold the scope. The response lists what it does hold. |
| 404 | No such resource in this version. |
| 429 | Over a rate limit. Wait for Retry-After seconds. |
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.
| Event | When |
|---|---|
punch.recorded | Somebody clocked in, out, or moved to another job. Meals and rest attestations are not punches and do not fire this. |
shift.approved | A day became payable. Fired on the transition, so an edit to an already-approved shift does not send it again. |
timeoff.decided | A request was approved or declined. |
expense.decided | A receipt was approved or declined. Carries the amount. |
{
"event": "shift.approved",
"at": 1756400400000,
"data": {
"shift_id": "s_9f3c…",
"worker_id": "w_41b2…",
"work_date": "2026-08-28",
"approved_by": "w_0c81…"
}
}
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.
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.
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.