CrewLink API
Schedule crews around the drive.
CrewLink schedules field-service work. Its availability knows every tech's booked jobs, their calendar, their skills and the drive between jobs at that time of day, so a time it offers is a time someone can actually make. The API is what the CrewLink booking page, office app and field app use; you can use all of it to connect your website, CRM or field tools.
Interactive reference: every endpoint, generated from the live schemas, is at /docs (the raw OpenAPI 3 document is at /v1/openapi.json).
These guides online: /docs/guides, public, with search and a page per guide (/docs/guides/<file name without .md>, this page is the index).
#Guides
| Guide | For |
|---|---|
| Booking page | Taking bookings on your website: services, open times, holds, bookings, deposits, out-of-area requests, and the homeowner's links |
| Office | Running the schedule from your CRM or back office: jobs and the intake queue, team, branches and services, time off, live board, emergencies, client companies, roles and invitations, technician pay, invoices, reports, Wins, your CrewLink plan |
| Field app | Tech-facing tools: the day, job stages, check-in, location, photos, parts, calls, and taps queued offline |
| CrewConnect | Sending work to and receiving work from partner companies and independent contractors |
| Messaging | Messages between your people: the company channel, job threads, office lines, direct messages and CrewConnect partner channels, with who-can-message-whom rules |
| Webhooks | Signed events for bookings, jobs, payments, messages and CrewConnect, with verification code |
| Platforms | Building a product on CrewLink for the companies that grant you access (the integration guide): platform keys and scopes, the booking flow end to end, external ids, job files, techs' hours and bases, one webhook subscription, the sandbox |
| Sign-in | Email-code and passkey sign-in, and sessions, for apps used by people |
| Admin | CrewLink staff only: super admins, every company, audited act-as owner sessions, platforms and grants, the audit log |
| Sign-up | Self-serve sign-up: the plans and rates, a new company and its owner, the card on file (Stripe Checkout, 30-day trial) |
| Changelog | What changed, newest first |
#Quick start
export CREWLINK=https://go.crewlink.ai
# 1. A company's services (no key needed)
curl $CREWLINK/v1/public/northline
# 2. Open times for a repair visit at an address, the next 7 days
curl -X POST $CREWLINK/v1/public/northline/availability \
-H 'content-type: application/json' \
-d '{"serviceId": 2, "lat": 41.955, "lng": -87.675, "days": 7}'
# 3. Today's schedule from the office side (company API key)
curl $CREWLINK/v1/office/schedule -H "authorization: Bearer $KEY"Then follow Booking page to hold a time and book it.
#Base URL and environments
| Environment | Base URL |
|---|---|
| Live | https://go.crewlink.ai: the only host today. Real companies and the test companies share it; ask CrewLink for an API key for a test company, and platforms get the acme-test sandbox (Platforms: sandbox). Payments are Stripe test mode until live payments are switched on |
| Later | https://api.crewlink.ai is reserved for the API and does not resolve yet; don't use it |
All paths start with /v1. Requests and responses are JSON (content-type: application/json), UTF-8, over HTTPS only.
#Authentication
Public booking-page endpoints and the homeowner's links need no key. A website that calls them can send an optional public integration key (X-CrewLink-Key) to get its own limits; it is not a credential (see Calling from a browser). Everything else takes a bearer token:
Authorization: Bearer <id>.<secret>There are four kinds of token. The first three look the same; CrewLink tells them apart by the id.
| Token | What it is | Acts as | Lifetime | Get it |
|---|---|---|---|---|
| Company API key | <company id>.<secret> |
The owner | Until rotated (POST /v1/office/api-key/rotate, owner only) |
From CrewLink when your company is set up |
| User token | <user id>.<secret> |
That user, with their role | Until the user is deactivated or re-keyed (re-keying also ends their sessions) | POST /v1/office/users (owner only), shown once; see Office: users |
| Session | <session id>.<secret> |
That user, with their role | 30 days | Sign-in (emailed code or passkey) |
| Platform key | pl_<platform id>.<secret>, sent with X-CrewLink-Company: <company slug or id> |
The owner of a company that granted that platform access, within the scopes it granted (read, write, webhooks); never users, keys, settings, plan, money, messages or the field app |
Until CrewLink replaces it; a company's revoke ends it for that company at once | From CrewLink, for products built on it; see Platforms |
CrewLink's own staff can also hold a 1-hour act-as session for your company (support, with your say-so): it acts as your owner, can't move money or mint keys or tokens, and every request it makes is logged with their name. GET /v1/office/me shows actingAs while one is in use. See Admin.
A platform key with no X-CrewLink-Company gets 400 company_required; a company that hasn't granted it answers 404; a call outside the grant's scopes gets 403 scope. Every platform call is logged, and its rate limit is 1,200 a minute per key (instead of per IP).
Use the company API key for server-to-server integrations (website back end, CRM sync). Use user tokens for a device or integration that should act as one person (a tech's tablet, a dispatcher tool). Use sessions for apps people sign in to. Keep every token on a server or in the device's secure storage; never put one in web page code. The company API key acts as the owner: anyone who reads it from a page can see and change your whole schedule. The only key that belongs in a page is the integration key.
#Roles
| Role | Can |
|---|---|
owner |
Everything, including users, company settings, billing plan, webhooks, the company API key and CrewConnect pay terms |
dispatcher |
Office work: schedule, jobs, team, branches, services, time off, emergencies, requests, trips, invoices, reports, CrewConnect jobs. Not users, settings, plan, webhooks, the API key or pay terms |
field_manager |
Everything a dispatcher may do, for a team of dispatchers (teamLeadId): sees what the team filed and invites dispatchers to it |
tech |
The field app endpoints, for jobs on their own schedule only |
Each office user also has a scope: company (the default for owners and dispatchers), team (the default for field managers) or own. People with team or own scope see other jobs as busy time, change only jobs in their scope, and get 403 scoped on company-wide endpoints (reports, KPIs, exports, money, settings, the live board, clients, webhooks). See Roles and scopes.
A missing or invalid token gets 401 unauthorized. A valid token whose role isn't allowed gets 403 forbidden. A token only ever sees its own company's data: ids from another company answer 404 not_found, never 403, so they reveal nothing.
#Conventions
#Times
Every company has an IANA time zone (for example America/Chicago), returned as timezone by GET /v1/public/{slug}, and each branch can have its own (a Chicago company's Denver branch runs on America/Denver, its London branch on Europe/London). A job is on the clock of the branch serving its address; a tech's working hours, days and "today" are on their branch's clock, including across daylight-saving changes. Answers with wall-clock times say which zone in timezone. A company in one zone sees no difference.
Responses give a time two ways:
{ "start": "2026-10-13T19:30:00.000Z", "local": "2026-10-13T14:30" }startis the real instant, ISO 8601 in UTC. Store and compare this one.localis the wall clock where the job is (timezone),YYYY-MM-DDTHH:mm, with no offset. Show this one to people.
Requests take ISO 8601 instants with an offset (2026-10-13T19:30:00Z or 2026-10-13T14:30:00-05:00). The simplest correct thing is to send back a start exactly as CrewLink gave it to you. Office bookings and moves also take local (the wall clock where the job is) instead. Dates (from, date, YYYY-MM-DD) are local calendar dates in the zone they're about: where the address is for availability, the tech's branch for a tech's day.
#Countries, phone numbers and distances
A company has a country (US, GB, DE, ...) and each branch may have its own. Texts go to mobile numbers in those countries only (a US company with a London branch texts US, Canadian and UK numbers); a number without + and a country code is read the way the job's country writes it. Address search with a slug looks in those countries. Distances in the API are always metric (km, areaKm, ...PerKmCents); show them in the company's units (mi or km, from GET /v1/public/{slug} or GET /v1/office/me).
A few office fields describe a working day as minutes from local midnight: 480 is 8:00 AM, 1020 is 5:00 PM. They are called out where they appear.
Online start times are offered on a 15-minute grid, and nothing sooner than one hour from now.
#Money
Amounts are integers in the currency's minor unit (cents for USD) with an ISO 4217 code alongside:
{ "priceCents": 8900, "depositCents": 4900, "currency": "USD" }8900 with USD is $89.00. Never send or expect decimals.
#Ids
Companies, users, sessions, webhook endpoints, CrewConnect connections and photos have UUID ids (strings). Jobs, bookings, holds, services, people (techs), blocks, invoices, requests, trips and most other records have integer ids. A booking id, a hold id and a job id are the same number for the same job: a hold becomes a booking, and the booking is a job on the office schedule.
#Errors
Every error has the same body:
{ "error": "slot_taken", "message": "That time was just taken. Pick another." }Branch on error, the stable machine code. message is a plain-English sentence you can show a person; its wording may change.
| Status | error |
When |
|---|---|---|
| 400 | bad_request |
The request couldn't be read (for example, malformed JSON) |
| 401 | unauthorized |
No token, or a token that isn't valid |
| 401 | bad_code |
Sign-in: the code is wrong, used or expired |
| 401 | bad_passkey, challenge_expired |
Passkey sign-in: the passkey didn't verify, or the loginId is used, unknown or older than 5 minutes |
| 400 | company_required |
A platform key without X-CrewLink-Company |
| 403 | forbidden |
Your role can't do this |
| 403 | scope |
A platform key whose grant doesn't include the scope this needs |
| 403 | scoped |
A user with team or own scope called a company-wide endpoint |
| 403 | not_yours |
CrewConnect: only the other side of the connection may change that setting |
| 403 | not_allowed |
CrewConnect: the sender didn't allow passing this job on |
| 404 | not_found |
No such record for you: unknown id, another company's id, a wrong or expired link token |
| 409 | slot_taken |
The time isn't free any more (another booking took it, or no qualified tech can make it) |
| 409 | too_soon |
The time is less than an hour from now |
| 409 | too_late |
A homeowner tried to cancel or move inside the company's cutoff (for example 24 hours); or a CrewConnect sender tried to take back a job that's done or has started |
| 409 | in_the_past |
An office booking or move to a time that has passed |
| 409 | not_offered |
A homeowner picked a time that wasn't one of the times offered |
| 409 | already |
Already done: a job stage that has been reached, a review already left |
| 409 | changed |
A suggested trip no longer fits the schedule; ask for fresh suggestions |
| 409 | exists |
CrewConnect: you already have a connection with that company |
| 409 | not_pending, not_active |
CrewConnect: the connection isn't in the state the action needs |
| 409 | nothing_to_accept, terms_changed |
CrewConnect pay terms: none to accept, or they changed since you read them |
| 409 | not_enough |
Inventory: not that many parts in that place |
| 409 | not_on_site |
Field app: check in only after arriving |
| 409 | no_visit_today |
Field app: a check-in on a job that isn't on your schedule today |
| 409 | in_progress |
The first request with this Idempotency-Key is still running |
| 409 | duplicate_external_ref |
An external id (externalRefs) is already on another job, person or block; the body names it (jobId, personId or blockId) |
| 409 | no_phone |
Field app: the tech or the customer has no usable phone number for a masked call |
| 409 | no_one_today |
Emergencies: no qualified tech can get there today |
| 409 | not_sendable |
Invoices: only a draft or sent invoice can be sent |
| 409 | not_voidable |
Invoices: a paid invoice can't be voided |
| 410 | hold_expired |
The hold ran out before it was booked |
| 422 | invalid_request |
The body, query or path failed validation (missing field, wrong type, out of range); message says which |
| 422 | invalid_url |
Webhooks: the URL isn't allowed |
| 422 | invalid_at |
Field app: at is more than 24 hours ago or in the future |
| 422 | idempotency_key_reused |
The Idempotency-Key was already used for a different request |
| 429 | rate_limited |
Too many requests from your IP (or integration key); wait for Retry-After |
| 429 | too_many_holds |
Too many live holds from your IP (or integration key) |
| 429 | slow_down |
Address search: one new lookup a second |
| 500 | server_error |
Something went wrong on our side. Retry with backoff |
| 502 | payments_unavailable |
The payment provider couldn't be reached; try again in a few minutes |
| 503 | not_configured |
A feature (calendar sync, masked calling) isn't switched on for this environment |
Each guide lists the codes each endpoint can return.
#Health
GET /v1/health needs no key and returns no data: 200 { "ok": true, "db": "ok", "release": "cl-…", "uptimeSeconds": 5321, "time": "…" } while the API and its database answer, 503 with "ok": false, "db": "down" when the database doesn't. Point uptime monitors at it (never cached).
#Rate limits
Limits are per client IP address (IPv6: per /64), per minute, counted separately for each endpoint listed. With a valid integration key the four public endpoints marked below count per key instead:
| Endpoint | Per IP | Per integration key |
|---|---|---|
| Every endpoint (default) | 600 a minute | |
POST /v1/auth/start |
5 a minute (and 5 codes an hour per person) | |
POST /v1/auth/verify |
10 a minute | |
POST /v1/auth/passkeys/login/options |
10 a minute | |
POST /v1/auth/passkeys/login |
10 a minute | |
POST /v1/public/{slug}/holds |
30 a minute | 300 a minute |
POST /v1/public/{slug}/bookings |
30 a minute | 300 a minute |
POST /v1/public/{slug}/requests |
30 a minute | 300 a minute |
GET /v1/public/geocode |
30 a minute, and one new lookup a second (cached answers are always served) | 300 a minute; still one new lookup a second per visitor IP |
On top of the request rates, one IP can have at most 3 live holds at a time, or one integration key 50 (a hold stops counting when it is booked or expires).
Over a limit, the API answers 429 with a Retry-After header (seconds) and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Wait at least Retry-After before trying again.
If your server calls the booking endpoints on behalf of many website visitors, they all share your server's IP and the per-IP limits. Send your company's integration key and they count per key instead. If 300 a minute or 50 live holds won't fit your traffic, talk to CrewLink before launch.
#Calling from a browser
A company's own website can call the public booking endpoints straight from the visitor's browser: no server of yours in between. Two settings, both owner-only and both in the office app under Settings > Online booking:
- Allowed origins (CORS). List your website's https origins (
https://www.yourcompany.com, up to 20) withPATCH /v1/office/settingsallowedOrigins. Browsers only hand CrewLink's answers to pages on those sites. - The integration key (optional, recommended). Make one with
POST /v1/office/integration-key/rotateand send it asX-CrewLink-Keyon every booking call.
#CORS rules
| Endpoints | Which pages may call them |
|---|---|
GET /v1/public/{slug}, POST /v1/public/{slug}/availability, /holds, /bookings, /requests |
Pages on that company's allowedOrigins |
GET /v1/public/geocode |
Pages on the allowedOrigins of the company in slug (or of the integration key's company). Send slug |
The homeowner's links: /v1/bookings/{id} (view, move, cancel), /v1/public/offers/{jobId}, /v1/public/invoices/{id}, /v1/public/reviews/{jobId} |
Any page (Access-Control-Allow-Origin: *): the secret token in the link authorizes them, and they only touch that one homeowner's booking |
GET /v1/openapi.json |
Any page |
| Everything else (office, field app, CrewConnect, sign-in) | No page. Call them from a server |
- Allowed:
GETandPOST, with the request headersContent-TypeandX-CrewLink-Key.Authorizationis not allowed from a page, so no bearer token can be sent cross-site. - Preflight (
OPTIONS) is answered with204and cached by the browser for 10 minutes. A page whose origin isn't listed gets no CORS headers (its preflight answers404), and the browser blocks the call. - Pages can read
Retry-Afterand theX-RateLimit-*headers. - No cookies are involved (
Access-Control-Allow-Credentialsis never sent); don't usecredentials: 'include'. - Changes to
allowedOriginsapply to the next request.
The hosted booking page and the iframe (Embed on your website) need none of this.
#The integration key
A public key, pk_..., that ties website traffic to your company:
- Safe to put in your website's code. It grants no access to data and opens no endpoint; anyone who copies it can only do what any visitor can already do on your booking page.
- What it changes: holds, bookings, out-of-area requests and address search count against the key instead of each visitor's IP, with higher ceilings (300 a minute per endpoint, 50 live holds), so many visitors behind one office network or a server-side caller aren't throttled as one person. Your traffic is attributed to your company.
- Only on your own endpoints. On another company's slug, or after it is replaced, the key is ignored and the request counts per IP as usual. A wrong key is never an error.
- One at a time. Making a new one stops the old one at once. Since anyone can read it from your page, someone could spend its limits; if that happens, make a new one and ask CrewLink.
- Never put the company API key (or any bearer token) in a page. It acts as the owner of your account.
// In your website's script. The key is public; the company API key never goes here.
const CREWLINK = 'https://go.crewlink.ai';
const headers = { 'content-type': 'application/json', 'x-crewlink-key': 'pk_<integration key>' };
const slots = await fetch(`${CREWLINK}/v1/public/northline/availability`, {
method: 'POST', headers, body: JSON.stringify({ serviceId: 2, lat: 41.955, lng: -87.675, days: 7 }),
}).then((r) => r.json());The full sequence is in Booking page: build it into your site.
#Idempotency and retries
Every write with a token takes an Idempotency-Key header (POST, PUT, PATCH and DELETE on the office, CrewConnect and platform endpoints; the field app's writes: job stages, check-ins, location, notes, parts): a retry with the same key and body within 24 hours returns the first response (with Idempotent-Replayed: true) instead of acting twice; the same key with a different body is 422 idempotency_key_reused; while the first still runs, 409 in_progress. Keys are per user (or per company API key, or per platform and company). Secrets aren't kept for replays: a replayed answer has fields such as manageToken and holdSecret empty, and calls that mint a credential (key rotations, user tokens, webhook secrets) ignore the header. See Field app: offline taps and retries. Jobs, people and blocks also take your own ids (externalRefs), which can't be used twice. Beyond that the flows are designed so retries are safe or detectable:
- Holds last 5 minutes (
expiresAt). A hold that isn't booked simply expires and frees the time; there is nothing to clean up. - Bookings consume a hold once. Retrying
POST .../bookingswith the sameholdIdafter a success returns 404 (the hold is no longer held), so a duplicate booking can't happen. If a booking request times out, look the booking up rather than holding again. - Cancels are safe to repeat: a second cancel answers 404 because there is no active booking left.
- Job stages only move forward; repeating one answers 409
already. - Reviews are one per job; a second answers 409
already. - Webhooks may arrive more than once. Each delivery carries a unique id (
CrewLink-Deliveryheader andidin the body): store the ids you've processed and skip repeats. See Webhooks.
For 5xx answers and network errors, retry with exponential backoff (for example 1 s, 2 s, 4 s, up to 5 tries). Don't retry 4xx answers unchanged, except 429 after Retry-After.
#Text messages
CrewLink texts homeowners when a booking is confirmed, reminded (24 hours before), moved, cancelled, when the tech is on the way, when an invoice is sent and after the job (a review link). Texts go to mobile numbers in the company's countries and its branches' (US and Canada for a US company; see Countries, phone numbers and distances); a booking with another number still succeeds, it just gets no texts. The texts carry the company's name (or, for CrewConnect work, the brand the sender chose).
#Versioning
The API is versioned in the path (/v1). Within v1 we add endpoints, optional request fields, response fields and webhook events without notice; build clients that ignore fields and events they don't know. Anything that would break a correct client gets a new version and a notice in the changelog.