API examples
developerCopy-paste recipes in curl, Python, PowerShell and JavaScript.
Practical, copy-paste recipes for the Apolaris AI REST API (/api/v1/). Every example is shown in curl, Python, PowerShell and JavaScript — pick your tab. Use these to script reports, sync data, or wire up integrations without driving a browser session; every call is token-authenticated and scoped to your organization. For the bigger picture (auth model, pagination envelope, error codes, full endpoint table), see the REST API overview.
Setup
All requests need a personal API token sent as a bearer token. The token acts as its owner: it inherits that user's organization, visible sites, and can_* privileges, so it can never do more through the API than the user can in the UI.
- In the app, open Account → API tokens (
/customers/account/api-tokens/). - Click Generate token. Copy the raw value immediately — it is shown once and looks like
ztk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. - Set it up for the examples below:
export TOKEN=ztk_your_token_here
export BASE=https://your-domain/api/v1
$TOKEN = "ztk_your_token_here"
$BASE = "https://your-domain/api/v1"
$headers = @{ Authorization = "Bearer $TOKEN" }
import requests
BASE = "https://your-domain/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer ztk_your_token_here"
const BASE = "https://your-domain/api/v1";
const headers = { Authorization: "Bearer ztk_your_token_here" };
Treat tokens like passwords. Store them in a secret manager — never in client-side code or version control. If a token leaks, revoke it (or disable the user) to cut access immediately.
Revoke a token
A token's lifecycle is a round-trip on the same Account → API tokens page (/customers/account/api-tokens/):
- Open the page. It lists your tokens by name and prefix (the full value is never shown again after creation).
- Click Revoke next to the token you want to retire and confirm.
- From that moment, every API call using that token fails authentication — there is no grace period and revocation cannot be undone.
You can only see and revoke your own tokens here. To cut off someone else's access, a manager (or a user with can_manage_privileges) disables that user account, which invalidates all of its tokens. Generate a fresh token to resume; the prefix changes, so update wherever the old one was stored.
Confirm who you are
GET /me/ returns your identity, privileges, and visible site ids. It's the fastest way to verify a token works and to see what it's allowed to do.
curl -H "Authorization: Bearer $TOKEN" "$BASE/me/"
me = session.get(f"{BASE}/me/").json()
print(me["domain"]["name"], me["is_manager"])
Invoke-RestMethod -Uri "$BASE/me/" -Headers $headers
const me = await (await fetch(`${BASE}/me/`, { headers })).json();
console.log(me.domain.name, me.is_manager);
X-API-Key: ztk_… is accepted as a fallback if you can't set an Authorization header.
List and search customers
List endpoints are paginated. Control results with ?page=N and ?page_size=N (default 50, max 200), and search with ?q= (name, email or phone).
curl -H "Authorization: Bearer $TOKEN" "$BASE/customers/?page=2&page_size=100"
curl -H "Authorization: Bearer $TOKEN" "$BASE/customers/?q=ana"
page = session.get(f"{BASE}/customers/", params={"page": 2, "page_size": 100}).json()
print(page["count"], "customers total")
hits = session.get(f"{BASE}/customers/", params={"q": "ana"}).json()
Invoke-RestMethod -Uri "$BASE/customers/?page=2&page_size=100" -Headers $headers
Invoke-RestMethod -Uri "$BASE/customers/?q=ana" -Headers $headers
const page = await (await fetch(`${BASE}/customers/?page=2&page_size=100`, { headers })).json();
const hits = await (await fetch(`${BASE}/customers/?q=ana`, { headers })).json();
Create a customer
Creates return HTTP 201 with the new object as JSON. Requires can_add_customer.
curl -X POST "$BASE/customers/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"first_name":"Ana","last_name":"Lopez","phone":"5551234567","email":"[email protected]"}'
body = {"first_name": "Ana", "last_name": "Lopez", "phone": "5551234567", "email": "[email protected]"}
customer = session.post(f"{BASE}/customers/", json=body).json()
print("created", customer["id"])
$body = @{ first_name = "Ana"; last_name = "Lopez"; phone = "5551234567"; email = "[email protected]" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$BASE/customers/" -Headers $headers -ContentType "application/json" -Body $body
const body = { first_name: "Ana", last_name: "Lopez", phone: "5551234567", email: "[email protected]" };
const customer = await (await fetch(`${BASE}/customers/`, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify(body),
})).json();
first_name is the only required field. Email is unique per organization, so posting an email that already belongs to another customer returns HTTP 400 with {"error": "validation", "detail": "A customer with this email already exists."} — handle it as a client error, not a crash:
resp = session.post(f"{BASE}/customers/", json=body)
if resp.status_code == 400:
print("duplicate or invalid:", resp.json()["detail"])
else:
customer = resp.json()
If you omit email, Apolaris assigns an internal placeholder address so the record still saves — you will never hit the duplicate-email error by leaving email blank.
Create a booking
Provide an existing customer_id, or pass customer details to match or create one. Requires can_add_booking.
curl -X POST "$BASE/bookings/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"customer_id":42,"service_id":3,"employee_id":7,"site_id":1,"date":"2026-07-01","time":"10:00","duration_minutes":60}'
booking = session.post(f"{BASE}/bookings/", json={
"customer_id": 42, "service_id": 3, "employee_id": 7, "site_id": 1,
"date": "2026-07-01", "time": "10:00", "duration_minutes": 60,
}).json()
$body = @{ customer_id=42; service_id=3; employee_id=7; site_id=1; date="2026-07-01"; time="10:00"; duration_minutes=60 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$BASE/bookings/" -Headers $headers -ContentType "application/json" -Body $body
const booking = await (await fetch(`${BASE}/bookings/`, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ customer_id: 42, service_id: 3, employee_id: 7, site_id: 1, date: "2026-07-01", time: "10:00", duration_minutes: 60 }),
})).json();
If you create a booking with customer details (rather than a customer_id) and no match is found, a walk-in customer record is created as part of the booking — exactly as in the calendar UI. Pass a customer_id to avoid minting new records.
Booking response fields
The request body uses date + time (separate, local-clock fields), but the response does not echo a time key. It returns a flattened booking object whose timing lives in start_time / end_time. Read those — there is no time field to read back.
| Field | Type | Notes |
|---|---|---|
id |
int | The Apolaris booking id — use it as your idempotency key. |
start_time |
string | ISO 8601 datetime, e.g. 2026-07-01T10:00:00+00:00. Parse with datetime.fromisoformat(...). |
end_time |
string | ISO 8601 datetime; start_time + duration_minutes. |
duration_minutes |
int | Length of the appointment in minutes. |
status |
string | Machine value (booked, confirmed, in_progress, cancelled, …). |
status_display |
string | Human-readable, localised label for status. |
customer_id / customer_name |
int / string | The booked customer. |
employee_id / employee_name |
int / string | The assigned staff member. |
service_id / service_name |
int / string | The booked service. |
site_id / site |
int / string | The site (location) id and name. |
has_sale |
bool | true once a sale has been recorded against this booking. |
notes |
string | Free-text notes. |
There is no time key in a booking response. Reading booking["time"] raises a KeyError. To get the time of day, parse start_time (for example datetime.fromisoformat(b["start_time"]).strftime("%H:%M")).
Record a sale
Send line items (the amount is computed) or a flat amount. Requires can_manage_sales.
curl -X POST "$BASE/sales/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"customer_id":42,"employee_id":7,"date":"2026-07-01","items":[{"service_id":3,"description":"Gel manicure","unit_price":"450","quantity":1}]}'
sale = session.post(f"{BASE}/sales/", json={
"customer_id": 42, "employee_id": 7, "date": "2026-07-01",
"items": [{"service_id": 3, "description": "Gel manicure", "unit_price": "450", "quantity": 1}],
}).json()
$body = @{ customer_id=42; employee_id=7; date="2026-07-01"; items=@(@{ service_id=3; description="Gel manicure"; unit_price="450"; quantity=1 }) } | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri "$BASE/sales/" -Headers $headers -ContentType "application/json" -Body $body
const sale = await (await fetch(`${BASE}/sales/`, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ customer_id: 42, employee_id: 7, date: "2026-07-01",
items: [{ service_id: 3, description: "Gel manicure", unit_price: "450", quantity: 1 }] }),
})).json();
unit_price is sent as a string ("450") to avoid float-rounding surprises. When you send items, the sale total is computed from the line items and any flat amount you also pass is ignored. The flat amount is used only when no items are written — and if you omit both items and amount, the sale is created with an amount of 0, not an error.
A sale is not just a number — recording one touches inventory, loyalty and your analytics. The amount you post flows straight into revenue reports and any per-staff or loyalty calculations, so a sale created with amount 0 (no items, no amount) will silently understate those reports. Pass real items or a real amount.
Date-filtered bookings
Most list endpoints accept date and relationship filters. For bookings, combine ?site=, ?employee=, ?customer=, ?status=, ?date_from=, and ?date_to=.
curl -H "Authorization: Bearer $TOKEN" "$BASE/bookings/?site=1&date_from=2026-06-22&date_to=2026-06-28"
week = session.get(f"{BASE}/bookings/", params={"site": 1, "date_from": "2026-06-22", "date_to": "2026-06-28"}).json()
Invoke-RestMethod -Uri "$BASE/bookings/?site=1&date_from=2026-06-22&date_to=2026-06-28" -Headers $headers
const week = await (await fetch(`${BASE}/bookings/?site=1&date_from=2026-06-22&date_to=2026-06-28`, { headers })).json();
Page through every result
The response envelope exposes next (the next page number, or null on the last page). Loop until it runs out:
results, page = [], 1
while page:
data = session.get(f"{BASE}/customers/", params={"page": page, "page_size": 200}).json()
results += data["results"]
page = data["next"]
print(len(results), "customers")
let results = [], page = 1;
while (page) {
const data = await (await fetch(`${BASE}/customers/?page=${page}&page_size=200`, { headers })).json();
results = results.concat(data.results);
page = data.next;
}
console.log(results.length, "customers");
Find an open appointment slot (availability)
There is no "give me availability" endpoint — and that's deliberate: you compute open slots from the same live data the calendar uses, so the answer is always current. Fetch a staffer's existing bookings for a day, then subtract them from the working window you operate in.
from datetime import datetime, timedelta
def open_slots(employee_id, site_id, day, service_minutes=60,
open_at="10:00", close_at="19:00", step=30):
"""Return free start times (HH:MM) for one employee on one day."""
booked = session.get(f"{BASE}/bookings/", params={
"employee": employee_id, "site": site_id, "date_from": day, "date_to": day,
}).json()["results"]
# A booking response has NO "time" key — derive the start instant from the
# ISO "start_time" field (e.g. "2026-07-01T10:00:00+00:00"). Drop the
# timezone so it compares cleanly with the naive open_at/close_at window.
busy = [(datetime.fromisoformat(b["start_time"]).replace(tzinfo=None),
int(b.get("duration_minutes") or service_minutes)) for b in booked]
free, t = [], datetime.strptime(f"{day} {open_at}", "%Y-%m-%d %H:%M")
end = datetime.strptime(f"{day} {close_at}", "%Y-%m-%d %H:%M")
while t + timedelta(minutes=service_minutes) <= end:
slot_end = t + timedelta(minutes=service_minutes)
clash = any(t < b_start + timedelta(minutes=b_min) and b_start < slot_end
for b_start, b_min in busy)
if not clash:
free.append(t.strftime("%H:%M"))
t += timedelta(minutes=step)
return free
slots = open_slots(7, 1, "2026-07-01", service_minutes=60)
print(slots) # ['10:00', '10:30', '14:00', ...]
# Book the first free slot:
if slots:
session.post(f"{BASE}/bookings/", json={
"customer_id": 42, "service_id": 3, "employee_id": 7, "site_id": 1,
"date": "2026-07-01", "time": slots[0], "duration_minutes": 60,
})
v1 doesn't expose shifts, leave, or per-site opening hours, so pass your own open_at/close_at window. The create endpoint still runs the full conflict and leave checks server-side, so a booking can never double-book even if your window is generous.
Want availability answered conversationally, with AI? That's exactly what the Contact Center's AI booking agent does — it reads this same live calendar, offers open times in the customer's language, and books on confirmation, 24/7, with no slot logic for you to build. Point customers at your WhatsApp number and let the agent handle availability end-to-end. See Contact Center (CCaaS).
Take bookings from your own website or app
Turn an external booking form into a real appointment in one call. Pass customer details instead of a customer_id and Apolaris matches an existing customer (by phone/email) or creates a walk-in — exactly like the calendar UI.
curl -X POST "$BASE/bookings/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"first_name":"Ana","last_name":"Lopez","phone":"5551234567","email":"[email protected]",
"service_id":3,"site_id":1,"date":"2026-07-01","time":"10:00","duration_minutes":60}'
booking = session.post(f"{BASE}/bookings/", json={
"first_name": "Ana", "last_name": "Lopez", "phone": "5551234567", "email": "[email protected]",
"service_id": 3, "site_id": 1, "date": "2026-07-01", "time": "10:00", "duration_minutes": 60,
}).json()
This is the cleanest way to wire a marketing site, a custom mobile app, or a chatbot to your real calendar without rebuilding booking logic. The token needs can_add_booking (and can_add_customer if a new customer must be created).
Sync bookings to an external system
There are no outbound webhooks on /api/v1/, and you don't need them: poll a rolling window of dates and upsert. Because reads are idempotent and replica-safe, you can run this every few minutes without side effects.
from datetime import date, timedelta
today = date(2026, 7, 1) # pass a fixed date in jobs; avoid drift
window_end = (today + timedelta(days=30)).isoformat()
results, page = [], 1
while page:
data = session.get(f"{BASE}/bookings/", params={
"date_from": today.isoformat(), "date_to": window_end,
"page": page, "page_size": 200,
}).json()
results += data["results"]
page = data["next"]
for b in results:
upsert_external_calendar(b) # your code: Google Calendar, ERP, data warehouse…
print(len(results), "bookings synced")
Key each external record by the Apolaris booking id so re-running the sync updates rather than duplicates. Combine ?site= and ?employee= to fan a multi-site business out to per-location calendars.
Tips
- Why this beats spreadsheets and manual exports: these endpoints read and write the same live data your team uses, so a nightly script or a connected dashboard stays in sync automatically — no copy-paste, no stale CSVs, no second source of truth to reconcile.
- Read before you write. Reads require
can_view_*; writes require the matching add/edit privilege (managers bypass). A403 forbiddenmeans the token's user lacks the privilege — see the Privileges reference. - 404 means scope, not just absence. A
404 not_foundis returned for objects outside your organization — you can never see another tenant's data. - Test live in the browser. Open
/api/docs/(Swagger UI), click Authorize, paste your token, and try any endpoint interactively.
Building an external integration or webhook flow on top of these calls? See Integrations & webhooks for patterns and delivery details.