Developers and API
Connecting to the API
The base URL, the one header that authenticates you, and a first working request in four languages.
Open this in the appWhat the API is for
Everything the web app can do, another system can do too — read employees, push attendance from a turnstile, pull payslips into an accounting package, raise a leave request from an intranet. The web app is itself built on this API, so nothing is held back for internal use.
There are two ways to authenticate. People sign in with an email and password and receive a token that expires. Programs use an API key: one header, no expiry timer, and not tied to an individual who might leave the company. Use a key for anything unattended.
Find your base URL
Every path here is relative to your base URL — your API host followed by the version prefix. Open Settings → API & webhooks in the app: the guide there shows the exact base URL for your deployment with a copy button, because a self-hosted customer's differs from ours.
https://your-api-host/api/v1Create a key
In Settings → API & webhooks → API keys, create one and tick only the permissions the integration needs. A key can never do more than the person who created it, and a narrow key limits the damage if it leaks.
The key is shown once, at creation. We store only a hash of it, so it cannot be revealed again — copy it into your password manager or your server's environment variables straight away. If you lose it, revoke it and create another.
Your first request
Send the key in an x-api-key header. This lists five employees, which is the fastest proof that the key works and has read access.
curl "https://your-api-host/api/v1/employees?limit=5" \
-H "x-api-key: ct_live_xxxxxx_your_key_here"const res = await fetch("https://your-api-host/api/v1/employees?limit=5", {
headers: { "x-api-key": process.env.CHEFOTECH_API_KEY },
});
const body = await res.json();
if (!body.success) throw new Error(body.error.message);
console.log(body.data, body.meta);import os, requests
res = requests.get(
"https://your-api-host/api/v1/employees",
headers={"x-api-key": os.environ["CHEFOTECH_API_KEY"]},
params={"limit": 5},
timeout=60,
)
body = res.json()
if not body["success"]:
raise RuntimeError(body["error"]["message"])
print(body["data"])<?php
$ch = curl_init("https://your-api-host/api/v1/employees?limit=5");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-api-key: " . getenv("CHEFOTECH_API_KEY")]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($body["data"]);What comes back
Every response uses the same envelope, so one parser handles all of them. Rows are in data; paging is in meta.
{
"success": true,
"data": [{ "id": "6a9d…", "employeeCode": "EMP0001", "fullName": "Rohan Deshmukh" }],
"meta": { "page": 1, "limit": 5, "total": 12, "totalPages": 3, "hasNext": true, "hasPrev": false }
}Failures use the same envelope with success false. Branch on error.code, which is stable, rather than on message, which is written for people and may be reworded.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Enter a company name",
"details": [{ "field": "name", "message": "Required" }]
},
"requestId": "ea4791db-a21c-41ce-907d-ab6a43b13022"
}Quote requestId when reporting a problem — it locates the exact request in the server log.
Paging through a list
List endpoints all take the same parameters: page, limit, q for free-text search, and sort with a leading minus for descending. Loop until meta.hasNext is false rather than guessing how many pages there are.
async function* allEmployees(key) {
let page = 1;
for (;;) {
const url = "https://your-api-host/api/v1/employees?limit=100&page=" + page;
const res = await fetch(url, { headers: { "x-api-key": key } });
const body = await res.json();
yield* body.data;
if (!body.meta.hasNext) return;
page += 1;
}
}Status codes worth knowing
- 401 — no key, or it is wrong, expired or revoked. Check the header name is exactly x-api-key.
- 403 — the key is valid but was not granted that permission. The in-app endpoint reference names the one each endpoint needs.
- 404 — no such record, or it belongs to another organization. We answer 404 rather than 403 so ids cannot be probed for existence.
- 422 — validation failed; error.details names the field.
- 429 — rate limited. The default is 300 requests a minute per IP; wait for the window to roll over and continue.
- 202 — accepted and queued, used by long jobs such as payroll processing. Poll the resource until its status changes.