Office API
The office API is everything a company's back office does: the day board, the live board, the team, branches and services, time off, booking and moving jobs, same-day emergencies, users, roles, invitation links and tokens, client companies, out-of-area and client requests and trips, recurring work, inventory, job profit, the fleet, reports, KPIs and exports, billing and invoices, and webhook endpoints.
Each call is scoped to the company behind the token. You only ever see and change your own company's people, jobs and money.
Related pages:
- booking-page.md: the public booking page, the customer's manage link, out-of-area requests from customers, invoice and review links.
- field-app.md: what techs call from their phones (
/v1/tech/...). - crewconnect.md: sending work to partner companies and contractors (
/v1/connect/...). - messaging.md: messages between your people (
/v1/messaging/...): the company channel, a thread per job, each tech's office line, direct messages, partner channels, and the owner's rules for who can message whom. - webhooks.md: events, payloads, signatures and retries.
- sign-in.md: email sign-in for people (session tokens).
#Contents
- Before you start
- Endpoint index
- Schedule
- Live board
- Dispatch board
- Team
- Branches and services
- Time off and blocks
- Office jobs
- Jobs for integrations (list, detail, complete, notes, attention, catalog, open times, holds, files)
- Sites (one per property per client company, every visit ever made there)
- Intake and job search
- Emergencies
- Calendar view and calendar connections
- Credentials
- Users and tokens
- Roles and scopes
- Invitation links
- Client companies
- Out-of-area requests and trips
- Recurring work and memberships
- Inventory
- Job P&L
- Fleet
- Drive times (travel model)
- Reports and market data
- Where jobs stand and the heat map
- KPIs, exports and the weekly report
- Plan vs actual
- Technician pay
- Wins
- Billing, invoices, payouts and reviews
- Webhook endpoints
- Platforms
- Company, settings and the API key
#Before you start
Base URL: https://go.crewlink.ai (live today; api.crewlink.ai is not set up yet). For testing, ask CrewLink for an API key for a test company. The examples use $CREWLINK for the base URL and $KEY for a token:
export CREWLINK=https://go.crewlink.ai
export KEY='3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a7c8e90.<secret>'
curl -s "$CREWLINK/v1/office/me" -H "Authorization: Bearer $KEY"The API also serves an interactive reference at GET /docs, built from GET /v1/openapi.json.
#Authentication and roles
Send Authorization: Bearer <id>.<secret> on every call. These tokens work:
| Token | Looks like | Role |
|---|---|---|
| Company API key | <company id>.<secret> |
Acts as an owner. For server-to-server integrations. Replace it with POST /v1/office/api-key/rotate. |
| User token | <user id>.<secret>, from POST /v1/office/users |
The user's role: owner, field_manager, dispatcher or tech. Long-lived; replace it with POST /v1/office/users/{id}/token. |
| Session | <session id>.<secret>, from email sign-in (sign-in.md) |
The signed-in user's role. Lasts 30 days. |
| Act-as session | <session id>.<secret>, from POST /v1/admin/companies/{id}/act-as |
CrewLink staff acting as the owner for 1 hour (support). Audited per request; never reaches payouts, payments, plan and billing changes, closing or paying pay periods, API, integration and sign-in tokens, platform grants or passkeys (403 acting_as). |
| Platform key | pl_<platform id>.<secret> plus X-CrewLink-Company: <company slug or id> |
Another product the company let act for it: the owner, within the scopes the company granted (read, write, webhooks), never users, keys, settings, plan, money or CrewConnect writes. See platforms.md and Platforms. |
Every endpoint on this page accepts owner, field manager and dispatcher tokens (a field manager can do whatever a dispatcher can). A tech token gets 403 forbidden here (techs use field-app.md). Field managers and dispatchers whose scope is team or own see only their own requests and jobs, and get 403 scoped on the whole-company endpoints: see Roles and scopes. A few calls are owner only:
| Owner only | |
|---|---|
POST /v1/office/users, PATCH /v1/office/users/{id}, POST /v1/office/users/{id}/token, POST /v1/office/users/{id}/deactivate, POST /v1/office/users/{id}/reactivate |
Add users, change their role, scope or team, re-key them, turn them off and on |
POST /v1/office/users/{id}/invite, POST /v1/office/invitations |
Invite people. Owners invite anyone; field managers invite dispatchers to their team; dispatchers invite techs |
POST /v1/office/clients, PATCH /v1/office/clients/{id}, POST /v1/office/clients/{id}/api-key |
Set up, change and turn off client companies |
POST /v1/office/api-key/rotate |
Replace the company API key |
POST /v1/office/integration-key/rotate |
Create or replace the website integration key |
PATCH /v1/office/plan, PUT /v1/office/billing/plan |
Change plan, interval or spend alert |
POST /v1/office/billing/portal, POST /v1/office/billing/checkout |
The card on file (Stripe) |
PATCH /v1/office/settings |
Company rules |
POST /v1/office/webhooks, DELETE /v1/office/webhooks/{id} |
Add or remove webhook endpoints |
POST /v1/office/platforms/{slug}/grant, PATCH /v1/office/platforms/{slug}/grant, DELETE /v1/office/platforms/{slug}/grant |
Let a platform act for the company, change its options (homeowner messaging), or stop it |
A missing, malformed, wrong or deactivated token gets 401 unauthorized. A deactivated user's sessions stop working too.
Idempotency-Key. Every write on this page (POST, PUT, PATCH, DELETE) takes an optional Idempotency-Key header: a retry with the same key and body within 24 hours gets the first answer back (Idempotent-Replayed: true) instead of acting twice; the same key with a different body is 422 idempotency_key_reused; while the first is still running, 409 in_progress. Keys are per user (or per company API key, or per platform and company). 5xx answers aren't kept.
#Times, money and ids
- Time zones per branch. The company has a default IANA zone, and each branch can have its own (a Denver branch of a Chicago company). A job's times are on the clock of its address (a branch whose area holds it gives its zone), and a person's hours and day on their own clock (their branch's; without a branch, their home's; or one set on them); responses that carry wall-clock times say which zone with
timezone. A company in one zone sees no difference. See the person's time zone. - Request times are ISO 8601 datetimes with an offset:
2026-11-16T16:00:00Zor2026-11-16T10:00:00-06:00. Office bookings and moves also takelocalinstead: the wall clock where the job is (2026-11-16T10:00), so you don't need to know the branch's zone. Dates areYYYY-MM-DD, read in the zone they're about (a tech's day in their branch's zone; a company report in the company's). - Response times come in two forms.
start(andeta,expiresAt,at, ...) is the real instant in UTC, e.g.2026-11-16T16:00:00.000Z.localis the wall clock where the job is,YYYY-MM-DDTHH:mm, e.g.2026-11-16T10:00, with its zone intimezone. A few board views (the schedule, trip stops) give only wall-clock times; each section says so. - Distances are metric in the API (
km,areaKm,perKmCents,costPerKmCents). Show them in the company'sunits(miorkm, from GET /v1/office/me). - Minutes after midnight. Working hours and the on-call window are minutes after local midnight:
480is 8:00,1020is 17:00,1440is the end of the day. Each day's hours are[start, end]with0 <= start < end <= 1440(anything else is422). - Money is an integer in minor units (
...Cents:12900is $129.00) plus an ISO currency code where it can vary. - Ids. Jobs, people, services, blocks, credentials, parts and invoices have integer ids. Users, webhook endpoints, recurrences, trips, vehicles and payouts have UUIDs. A booking id, a job id and an out-of-area request id are the same number for the same job.
#Errors
Every error has the same body:
{ "error": "slot_taken", "message": "No qualified tech can make that time." }| Status | error |
When |
|---|---|---|
| 400 | bad_request |
The body isn't valid JSON |
| 401 | unauthorized |
No token, or a bad, expired or deactivated one |
| 403 | forbidden |
Your role can't call this (a tech token, or a non-owner on an owner-only call) |
| 404 | not_found |
Nothing with that id in your company (another company's ids look exactly like missing ones) |
| 409 | varies | The request is valid but the schedule or state doesn't allow it (slot_taken, in_the_past, ...). Each endpoint lists its codes. When a time can't be booked, the body also has a reason; see Why a time can't be booked |
| 422 | invalid_request |
The body, query or path failed validation (wrong type, out of range, missing field, a date range that's too long). message says which |
| 429 | rate_limited |
Too many requests; see Rate limits |
| 500 | server_error |
Something went wrong on our side. Safe to retry reads; check state before retrying writes |
| 502 | payments_unavailable |
Only on sending an invoice, when the payment provider can't be reached |
The per-endpoint lists below leave out 401, 403 and 429, which any call can return. The OpenAPI spec (/v1/openapi.json) lists them on every route, with 400, 404 (a platform key's company that hasn't granted it), 422 and 500 where they apply.
#Why a time can't be booked
When the office books, moves or accepts a job at a time that can't be booked, the 409 body says why in message (written for the person at the desk) and in reason (for your code), and personId names the tech it's about when it's about one:
{
"error": "slot_taken",
"message": "That time is held for same-day emergencies (on-call reserve, 12:00–5:00 PM). Pick another time or tech.",
"reason": "on_call_reserve",
"personId": 1
}reason |
Meaning | Example message |
|---|---|---|
on_call_reserve |
The time overlaps the on-call window, which only emergencies can use | That time is held for same-day emergencies (on-call reserve, 12:00–5:00 PM). Pick another time or tech. |
outside_hours |
Outside the tech's working hours (a visit has to fit inside one of their windows that day), or a day they don't work | That's outside Maria Gomez's hours (8:00 AM–12:00 PM and 1:00–5:00 PM on Tuesdays): the 72-minute visit has to start and finish inside one of them. Pick another time or tech. |
time_off |
A block on their calendar (time off, or busy time from a linked calendar) | Maria Gomez has time off then (8:00 AM–12:00 PM). Pick another time or tech. |
no_skills |
They (or not enough people on the team) have the skills for the service | Maria Gomez doesn't have the skills for New system install. Pick another tech. |
not_on_team |
The tech you picked can't take it: inactive, missing a credential the job needs, or the job is past their max travel distance | Maria Gomez travels at most 20 miles from home, and this job is 26.4 miles away. Pick another tech. |
doesnt_fit |
Another job or the drive is in the way | That doesn't fit Maria Gomez's day: another job or the drive is in the way. Pick another time or tech. |
The first four are checked before the routing, so they're exact. Without picked techs, a reason is given only when it rules out everyone who could go (for example, everyone qualified is on call then). Out-of-area accepts answer cannot_reach instead of slot_taken when the tech can't get there in time; see accept.
#Rate limits
Each client IP can make up to 600 requests a minute across the API (a platform key: 1,200 a minute per key instead, wherever it calls from). Over the limit you get 429 rate_limited with a Retry-After header (seconds) and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Back off until Retry-After has passed.
#Endpoint index
O+D = owner, field manager and dispatcher (people with a team or own scope only where Roles and scopes says). O = owner only.
| Method | Path | Roles | Purpose |
|---|---|---|---|
| GET | /v1/office/schedule |
O+D | Everyone's day: visits and blocks |
| GET | /v1/office/live |
O+D | Live board: status, position and ETA for every tech today |
| GET | /v1/office/dispatch |
O+D | The whole dispatch board for a day in one read: lanes, stops with planned drives, live status, queue, risks, pins (ETag) |
| GET | /v1/office/dispatch/live |
O+D | The live part of the board: positions, field statuses, per-job ETA and risk (ETag) |
| GET | /v1/office/dispatch/routes |
O+D | Live map: each tech's position, heading and the route they are on (navigating or planned), legs with ETAs, stops (ETag) |
| GET | /v1/office/team |
O+D | Everyone on the team, with hours and skills |
| GET | /v1/office/team/{id} |
O+D | One person (the same object as a list item) |
| GET, PUT | /v1/office/availability-management |
O (GET: O+D) | Who owns technician availability: CrewLink or an integration |
| POST | /v1/office/team |
O+D | Add a person to the team |
| PATCH | /v1/office/team/{id} |
O+D | Change someone's details, branch, home address, hours (several windows a day), skills, status, mobile, designation, tax id, max travel distance or verified |
| GET | /v1/office/team/{id}/documents |
O+D | Someone's documents (insurance, licence, W-9...) |
| POST | /v1/office/team/{id}/documents |
O+D | Add a document: get an upload URL |
| POST | /v1/office/team/{id}/documents/{docId}/uploaded |
O+D | Mark a document uploaded |
| DELETE | /v1/office/team/{id}/documents/{docId} |
O+D | Remove a document |
| POST | /v1/office/team/{id}/deactivate |
O | Take someone off the schedule and turn off their sign-in |
| POST | /v1/office/team/{id}/reactivate |
O | Put someone back on the schedule |
| POST | /v1/office/team/{id}/reset-sign-in |
O | Sign someone out everywhere and replace their token |
| PUT | /v1/office/team/{id}/availability |
O+D | Replace someone's weekly hours (one or several windows a day) |
| POST, GET | /v1/office/team/{id}/bases |
O+D | Where someone starts and ends their days for a while, and the list |
| DELETE | /v1/office/team/{id}/bases/{baseId} |
O+D | Remove a base |
| GET | /v1/office/branches |
O+D | Your branches (service areas) |
| POST | /v1/office/branches |
O+D | Add a branch (its own time zone and country optional) |
| PATCH | /v1/office/branches/{id} |
O | Change a branch: name, place, area, time zone, country |
| GET | /v1/office/services |
O+D | Your services, including turned-off ones |
| POST | /v1/office/services |
O+D | Add a service |
| PATCH | /v1/office/services/{id} |
O+D | Change a service, or turn it off |
| GET | /v1/office/blocks |
O+D | Blocked time in a date range, with names |
| POST | /v1/office/blocks |
O+D | Block time (time off, training, appointments) |
| DELETE | /v1/office/blocks/{id} |
O+D | Remove a block |
| POST | /v1/office/first-available |
O+D | Every start time someone qualified can make at an address, with who would go |
| POST | /v1/office/jobs |
O+D | Book a job from the office |
| POST | /v1/office/jobs/{id}/move |
O+D | Move a job to another time and/or tech, or book an unscheduled one |
| POST | /v1/office/jobs/{id}/cancel |
O+D | Cancel a booked, unscheduled or queued job (no cutoff) |
| POST | /v1/office/jobs/{id}/history |
O+D | Correct a cancelled or done import's old time and techs (no checks, nothing sent) |
| GET | /v1/office/intake |
O+D | The intake queue: counts per tab and dispatch risks |
| GET | /v1/office/jobs.csv |
O+D | The job list as CSV |
| GET | /v1/office/intake/{id}/options |
O+D | Closest techs and their open times for a job without a time |
| POST | /v1/office/intake/{id}/discard |
O+D | Drop a job from the intake queue |
| GET | /v1/office/jobs/{id}/photos |
O+D | A job's photos |
| GET | /v1/office/jobs/{id}/texts |
O+D | A job's texts to the customer, newest first |
| GET | /v1/office/jobs |
O+D | Every job: search, intake tabs and filters, updatedSince, cursor pages, lookup by external id |
| GET | /v1/office/jobs/{id} |
O+D | One job in full: customer, visits, timeline, files, texts count |
| GET | /v1/office/sites |
O+D | Sites: one per property per client company, each with its visits: search, filters, updatedSince, cursor pages, lookup by external id |
| GET | /v1/office/sites/{id} |
O+D | One site: every visit and the whole timeline |
| PATCH | /v1/office/sites/{id} |
O+D (not team or own scope) | Set or clear a site's own external ids |
| POST | /v1/office/sites/{id}/merge |
O+D (not team or own scope) | Merge another site into this one (audited) |
| POST | /v1/office/sites/{id}/split |
O+D (not team or own scope) | Take a visit out to a site of its own, or to another (audited) |
| POST | /v1/office/jobs/{id}/complete |
O+D | Mark a job done from the office or an integration (silent or not) |
| POST | /v1/office/jobs/{id}/notes |
O+D | Add an office note |
| POST, DELETE | /v1/office/jobs/{id}/attention |
O+D | Flag a job as needing attention, or clear it |
| GET | /v1/office/catalog |
O+D | Your active services as the booking page lists them |
| POST | /v1/office/availability |
O+D | Open times at an address, without per-IP limits (excludeJobId) |
| POST | /v1/office/holds |
O+D | Hold a time for 5 minutes (book it with holdId) |
| POST | /v1/office/jobs/{id}/attachments |
O+D | Add a file: get an upload URL |
| POST | /v1/office/jobs/{id}/attachments/{attachmentId}/uploaded |
O+D | Confirm an upload |
| POST | /v1/office/jobs/{id}/attachments/upload |
O+D | Add a file in one call (up to 20 MB) |
| GET | /v1/office/jobs/{id}/attachments |
O+D | A job's files with download links |
| DELETE | /v1/office/jobs/{id}/attachments/{attachmentId} |
O+D | Remove a file |
| POST | /v1/office/emergencies |
O+D | Book a same-day emergency |
| GET | /v1/office/calendar |
O+D | Day-by-day totals for month and week views |
| GET | /v1/office/calendars |
O+D | Linked Google and Outlook calendars and their sync status |
| POST | /v1/office/team/{id}/credentials |
O+D | Add a license, insurance, certification or background check |
| GET | /v1/office/credentials |
O+D | Credentials, soonest to expire first |
| DELETE | /v1/office/credentials/{id} |
O+D | Remove a credential |
| POST | /v1/office/users |
O | Add a user and get their token (and email them an invite) |
| GET | /v1/office/users |
O+D | Everyone who can sign in |
| POST | /v1/office/users/{id}/invite |
O, D for techs | Email an invite with a sign-in code |
| POST | /v1/office/team/invite-all |
O | A tech sign-in for every person with an email and no sign-in, each emailed an invite |
| POST | /v1/office/users/{id}/token |
O | Issue a new token |
| POST | /v1/office/users/{id}/deactivate |
O | Turn off a user |
| POST | /v1/office/users/{id}/reactivate |
O | Turn a user back on |
| PATCH | /v1/office/users/{id} |
O | Change someone's role, scope or team |
| POST | /v1/office/invitations |
O, FM for dispatchers, D for techs | Invite someone with a one-time link (and email it) |
| GET | /v1/office/invitations |
O+D | Pending invitations (or all) |
| POST | /v1/office/invitations/{id}/revoke |
O, or whoever made it | Withdraw an invitation |
| POST | /v1/office/invitations/{id}/resend |
O, or whoever made it | A fresh link (and email) |
| GET | /v1/public/invitations/{token} |
No key | What an invitation link is for (sign-in.md) |
| POST | /v1/public/invitations/{token}/accept |
No key | Accept: creates the user and signs them in |
| GET | /v1/office/clients |
O+D | Your client companies, with people and open requests |
| POST | /v1/office/clients |
O | Set up a client company |
| GET | /v1/office/clients/{id} |
O+D | One client: people, latest jobs, pending invitations |
| PATCH | /v1/office/clients/{id} |
O | Rename, email domains, turn on or off |
| POST | /v1/office/clients/{id}/api-key |
O | Issue the client's API key |
| GET | /v1/me/tour |
Any role | Your progress through the in-app tours |
| PATCH | /v1/me/tour |
Any role | Save your place in a tour |
| GET | /v1/me/prefs |
Any role | Your time zone, theme, colour filter and this sign-in |
| PATCH | /v1/me/prefs |
Any role | Change your preferences |
| GET | /v1/me/analytics |
Any role | What CrewLink's own apps send to product analytics for this sign-in |
| GET | /v1/office/requests |
O+D | Waiting requests (out-of-area and filed by clients), with workable times |
| POST | /v1/office/requests |
O+D | File a visit request yourself (or as a platform), for one of your client companies if you like |
| POST | /v1/office/requests/{id}/accept |
O+D | Book a waiting request |
| POST | /v1/office/requests/{id}/decline |
O+D | Decline a waiting request |
| GET | /v1/office/trips/suggestions |
O+D | Trips that would serve waiting requests profitably |
| POST | /v1/office/trips |
O+D | Book a trip |
| GET | /v1/office/trips |
O+D | Booked trips |
| POST | /v1/office/recurring |
O+D | Set up recurring work or a membership |
| GET | /v1/office/recurring |
O+D | Recurring work and memberships |
| POST | /v1/office/recurring/{id}/stop |
O+D | Stop a recurrence |
| POST | /v1/office/recurring/run |
O+D | Book everything due in the next 14 days now |
| POST | /v1/office/parts |
O+D | Add a part |
| GET | /v1/office/parts |
O+D | Parts with stock on hand |
| PUT | /v1/office/stock |
O+D | Set a stock count |
| POST | /v1/office/stock/transfer |
O+D | Move stock between the warehouse and trucks |
| GET | /v1/office/jobs/{id}/pnl |
O+D | Profit and loss for one job |
| POST | /v1/office/vehicles |
O+D | Add a vehicle |
| GET | /v1/office/fleet |
O+D | Vehicles, drivers and positions |
| GET | /v1/ops/travel |
O | How drive times are worked out: road data, rush-hour learning, your metro |
| GET | /v1/ops/brain |
O | The scheduling brain's training: recent runs, the settings each metro plans with, real days learned from |
| GET | /v1/office/reports |
O+D | KPIs for a date range |
| GET | /v1/office/market |
O+D | Weather ahead, fuel prices and what they mean |
| GET | /v1/office/reports/status |
O+D | Where the jobs filed in a range stand, the request funnel and cycle time |
| GET | /v1/office/reports/heatmap |
O+D | Jobs and techs per ~5 km area, by state and by branch |
| GET | /v1/office/kpis/catalog |
O+D | What each KPI means |
| GET | /v1/office/kpis |
O+D | Every KPI with the prior period, the change and a daily series, plus breakdowns |
| GET | /v1/office/pay |
O+D | Technician pay per week and tech |
| GET | /v1/office/pay/lines |
O+D | Every job, mileage day and adjustment behind a week |
| POST | /v1/office/pay/adjustments |
O+D | Add a bonus or deduction |
| DELETE | /v1/office/pay/adjustments/{id} |
O+D | Remove an adjustment (week still open) |
| POST | /v1/office/pay/periods/{start}/close |
O | Close an ended pay week |
| POST | /v1/office/pay/periods/{start}/reopen |
O | Reopen a closed week nobody has been paid for |
| POST | /v1/office/pay/periods/{start}/paid |
O | Mark a closed week paid, with a reference |
| GET | /v1/office/pay/settings |
O+D | Mileage rate, pay week start, what techs may see and change |
| PATCH | /v1/office/pay/settings |
O | Change them |
| GET | /v1/office/pay/rates |
O+D | Base pay per service and per-person rates |
| PUT | /v1/office/team/{id}/pay-rates/{serviceId} |
O | Someone's own base pay for a service |
| DELETE | /v1/office/team/{id}/pay-rates/{serviceId} |
O | Remove it |
| GET | /v1/office/exports/pay.csv |
O+D | The pay report as CSV |
| GET | /v1/office/exports/{file}.csv |
O+D | CSV of jobs, invoices, payments, KPIs, techs, services or plan vs actual |
| GET | /v1/office/reports.pdf |
O+D | The KPI report as a PDF |
| GET | /v1/office/brain/day |
O+D | One day's plan against what happened, stop by stop, with the day's numbers |
| GET | /v1/office/brain/summary |
O+D | Reviewed days as a series, and a date range's numbers together |
| GET | /v1/office/brain/days |
O+D | The most recent reviewed days, newest first |
| POST | /v1/office/brain/review |
O+D | Review a day now (stores its numbers, sends day.reviewed) |
| POST | /v1/office/brain/snapshot |
O+D | Record a day's plan as it stands now |
| GET | /v1/office/brain/settings |
O+D | The traffic-lookup budget and today's use |
| PATCH | /v1/office/brain/settings |
O | Change the traffic-lookup budget |
| GET | /v1/office/wins |
O+D | A week's goals, the wins feed, and each tech's streak and badges |
| PATCH | /v1/office/wins/settings |
O | Weekly goals and the leaderboard on or off |
| GET | /v1/office/wins/leaderboard |
O+D | The week's leaderboard: top 3 only |
| GET | /v1/office/billing |
O+D | Your plan and this month's bill, itemized |
| GET | /v1/office/billing/usage |
O+D | The live job meter, forecast, nudges and alerts |
| PATCH | /v1/office/plan |
O | Choose plan, interval and spend alert |
| PUT | /v1/office/billing/plan |
O | Change plan (older form) |
| GET | /v1/office/exports/plan-usage.csv |
O+D | The jobs counted on your plan in a month |
| POST | /v1/office/billing/portal |
O | Stripe Customer Portal link (card, invoices) |
| POST | /v1/office/billing/checkout |
O | Stripe Checkout link to put a card on file |
| POST | /v1/office/billing/checkout/complete |
O+D | Apply a finished checkout |
| POST | /v1/office/jobs/{id}/invoice |
O+D | Draft an invoice for a job |
| GET | /v1/office/jobs/{id}/invoice |
O+D | A job's current invoice |
| POST | /v1/office/invoices/{id}/send |
O+D | Text the customer a link to view and pay |
| GET | /v1/office/invoices |
O+D | Invoices, newest first, with customer and service |
| POST | /v1/office/invoices/{id}/void |
O+D | Void a draft or sent invoice |
| GET | /v1/office/payouts |
O+D | CrewConnect money owed both ways |
| GET | /v1/office/reviews |
O+D | Customer reviews and the average |
| POST | /v1/office/webhooks |
O | Subscribe a URL to events |
| GET | /v1/office/webhooks |
O+D | Your webhook endpoints |
| DELETE | /v1/office/webhooks/{id} |
O | Remove a webhook endpoint |
| GET | /v1/office/webhooks/{id}/deliveries |
O | An endpoint's delivery log: attempts, last status code and error, the body as sent |
| POST | /v1/office/webhooks/{id}/deliveries/{deliveryId}/retry |
O | Send one delivery again now |
| GET | /v1/office/platforms |
O+D | Platforms you've granted access, and their scopes |
| POST | /v1/office/platforms/{slug}/grant |
O | Let a platform act for your company |
| PATCH | /v1/office/platforms/{slug}/grant |
O | A grant's options: homeownerMessaging on or off |
| DELETE | /v1/office/platforms/{slug}/grant |
O | Revoke a platform's access |
| GET | /v1/office/me |
O+D | Who you are and which company |
| PATCH | /v1/office/settings |
O | Company rules: on-call window, cancellation cutoff, homeowner messaging, weekly report email, websites allowed to call the booking API, time zone, country, distance units |
| POST | /v1/office/api-key/rotate |
O | Replace the company API key |
| POST | /v1/office/integration-key/rotate |
O | Create or replace the website integration key |
#Schedule
#GET /v1/office/schedule
Everyone's day: each active person's visits (booked jobs, plus booking-page holds in progress) and calendar blocks on that date. Times here are wall clock only (YYYY-MM-DDTHH:mm), since the board is drawn in local time. Each tech's day and times are on their branch's clock (timezone on each tech): with branches in two zones, date=2026-11-16 is the 16th in Chicago for the Chicago techs and the 16th in Denver for the Denver techs, so a Denver job at 23:00 is on the Denver tech's 16th even though it is already the 17th in Chicago.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
date |
string | yes | YYYY-MM-DD, a local date |
client |
string | no | A client company id: only that client's jobs. none: only jobs with no client |
curl -s "$CREWLINK/v1/office/schedule?date=2026-11-16" -H "Authorization: Bearer $KEY"Response 200:
{
"date": "2026-11-16",
"techs": [
{
"id": 1,
"name": "Maria Gomez",
"timezone": "America/Chicago",
"visits": [
{
"jobId": 1042,
"start": "2026-11-16T10:00",
"end": "2026-11-16T11:15",
"status": "booked",
"jobStatus": "scheduled",
"overdue": false,
"needsAttention": false,
"attentionReason": null,
"service": "Seasonal tune-up",
"customer": "Pat Quinn",
"address": "2210 W Addison St, Chicago",
"sentBy": null,
"client": { "id": "9b1e6f2a-4c3d-4e8f-a7b6-2d1c0e9f8a75", "name": "Globex Solar" },
"filedBy": "Filed by Dana One · Globex Solar",
"hidden": false,
"state": "scheduled"
}
],
"blocks": [
{ "id": 17, "start": "2026-11-16T13:00", "end": "2026-11-16T17:00", "reason": "Dentist", "source": "office" }
]
},
{ "id": 2, "name": "James Okafor", "visits": [], "blocks": [] }
]
}| Field | Notes |
|---|---|
techs[] |
Every active person, including those with nothing booked |
visits[].status |
booked, or held while a customer is finishing a booking on the booking page (holds last 5 minutes) |
visits[].jobStatus |
The job's status as people read it: scheduled, en_route, on_site or done (see Job statuses) |
visits[].overdue |
The job's last visit has ended and it isn't marked done |
visits[].needsAttention, attentionReason |
The office flag and why (see POST /v1/office/jobs/{id}/attention) |
visits[].end |
The time blocked for the job, which may be longer than the service's listed duration (CrewLink learns how long each person takes) |
visits[].customer, address |
null when not known (e.g. a hold) |
visits[].sentBy |
For a job a CrewConnect partner sent you: that company's name. Otherwise null |
visits[].client |
The client company the job is for, or null |
visits[].filedBy |
Who filed or booked it and for which client, ready to show: Filed by Dana One · Globex Solar, Filed by Sam Office, or just the client's name. null for booking-page jobs and jobs booked with the company API key |
visits[].homeownerTexts |
false: the job sends its homeowner no texts or emails (Homeowner messaging); show a "Homeowner texts off" badge |
visits[].outsideHours |
true: booked outside the tech's working hours, honouring a time the old system had confirmed (import); show an "Outside hours" badge |
visits[].hidden |
true for someone with a team or own scope when the job isn't theirs: shown as busy time only (service is Busy; customer, address, client and filedBy are null) |
visits[].state |
What the office should read: held (a booking-page hold), scheduled, in_progress (the tech tapped on the way or arrived), overdue (the job's last visit has ended and nobody marked it done: chase it, as the old app's OVERDUE), completed (marked done) |
blocks[].source |
office (added through this API or the web app), google or outlook (busy time from a linked calendar) |
A multi-day job shows as one visit on each of its days.
Errors: 422 if date is missing or not YYYY-MM-DD.
#Live board
#GET /v1/office/live
Every tech working today with a traffic-light status, what they're doing, their last known position, and their next job with an ETA. Positions come from the field app. The board includes every active person who has a job today or working hours today.
No parameters.
curl -s "$CREWLINK/v1/office/live" -H "Authorization: Bearer $KEY"Response 200:
{
"at": "2026-10-12T15:30:00.000Z",
"counts": { "ok": 5, "tight": 1, "late": 1, "idle": 1 },
"techs": [
{
"id": 1,
"name": "Maria Gomez",
"status": "late",
"doing": "before_leaving",
"location": { "lat": 43.04, "lng": -87.91, "at": "2026-10-12T15:29:10.000Z" },
"next": {
"jobId": 1188,
"customer": "Jamie Cruz",
"start": "2026-10-12T14:00",
"eta": "2026-10-12T19:28:00.000Z",
"minutesLate": 28
}
},
{
"id": 4,
"name": "Diego Ramirez",
"status": "idle",
"doing": "done",
"location": null,
"next": null
}
]
}Status (the dot on the board):
status |
Colour | Meaning |
|---|---|---|
ok |
green | On site, or will reach the next job at least 5 minutes before its start |
tight |
yellow | Will arrive between 5 minutes before and 10 minutes after the start |
late |
red | Will arrive more than 10 minutes after the start |
idle |
grey | No more jobs today |
Doing:
doing |
Meaning |
|---|---|
on_site |
At a job now (tapped "arrived", or inside the job's booked time and not yet on the way elsewhere). If they have a later job, its ETA assumes they leave when this job is due to end, or now if it's already running over |
driving |
Tapped "on my way" to next |
before_leaving |
Hasn't left for next yet. The ETA assumes they leave on time, but never earlier than now, so a tech who can no longer make it from where they are shows late before the start time passes |
done |
Nothing left today (always with status: idle) |
Fields:
| Field | Notes |
|---|---|
at |
When the board was computed (UTC) |
location |
Last position the field app reported while the tech is on the clock (now and when it was taken: from 30 minutes before their first window or visit today to 30 minutes after their last), else null: never where a tech is off the clock, for any caller. at says how old it is. Positions older than 15 minutes are shown but not used for the ETA; the ETA then starts from their last job today, or home |
next.start |
Local wall clock |
next.eta |
Estimated arrival (UTC), from an estimated drive time. Treat null as "no estimate" |
next.minutesLate |
ETA minus start, in minutes. Negative means early. null when eta is |
#Dispatch board
#GET /v1/office/dispatch
The whole Dispatch page for one local day in one read: a lane per person working that day with their stops in order and the planned drive before each, time off, live status, position and ETA (on the tech's today), the intake queue, risks, counts and map pins. Made for apps that draw the board themselves (your platform's admin app reads it with a platform key); the full field list and an example are in platforms.md. Read-only.
| Query | |
|---|---|
date |
Required. Local date YYYY-MM-DD. Each lane is that date on the tech's own clock (tech.timezone), as on GET /v1/office/schedule |
branch |
A branch id: only that branch's people, and only queue jobs that branch serves. 404 not_found for an id that isn't one of the company's branches |
client |
A client company id: only that client's stops and queue jobs (none: jobs with no client). Lanes stay (everyone working), and utilization still counts the whole day |
curl -s "$CREWLINK/v1/office/dispatch?date=2026-10-12" -H "Authorization: Bearer $KEY"- Lanes: every active person with working hours that weekday or a visit that day (booked or held), sorted by name.
tech.hoursare that day's working windows in minutes after local midnight.utilizationis booked minutes over working-window minutes. - Stops: the visits in start order, each with the job's ids (
externalRefs), client, service, customer, address in parts, times (start/endinstants,local/localEndwall clock),state,jobStatus,stage,overdue, the attention flag andhomeownerTexts.driveMinutesBeforeis the planned drive from the previous stop (or home, or a base): from the day's plan snapshot the techs are working to (driveSource: "plan") while that tech's stops still match it, else the engine's prediction for the day as booked now ("predicted");nullfor holds.etaandminutesLateare live, while the tech is on the way to that stop.risk(levelon_time/at_risk/late,eta, signedminutesLate,reason,basis) covers every stop not reached yet today and every arrived stop;enRouteAt,arrivedAt,completedAtare the field taps;plannedOrder/actualOrdercompare the booked order with the order of arrival;actualDriveMinutesis the drive as it happened (platforms.md). - Live (only when
dateis the tech's today;nullotherwise):statusok,tight,late,idleoron_job(on site and on track) anddoing, by the live board's rules, plusfieldStatus(off,idle,en_route,on_site),currentJobIdandnextJobId;locationis the last position the field app sent (at, andaccuracy,heading,speedwhen the phone sent them), only while the tech is on the clock (now and when it was taken), for every caller. - Queue: the intake queue (every job with no time yet), oldest first, at most 100;
counts.unscheduledcounts all of it. - Risks:
lateandtight(live),overduestops on the day, andagingqueue jobs (waiting 3 or more days), each with a one-linemessage. - Pins: one per job on the board (
lat,lng,state,stage,techIds), for a map.
Who sees what. Platform keys (read is enough) see techs as "First L." (as they are when the grant says full names), customers without a phone, and a tech's location by the grant: full-names grants while the tech is on the clock (now and the position's time both within 30 minutes before their first window or visit that day and 30 minutes after their last); other grants only while the tech is working a job and the fix is at most 15 minutes old (platforms.md). The same rule applies to location on GET /v1/office/live. People with a team or own scope see other people's jobs as busy blocks (source: "job", no customer, address or client), no live status or positions, and only their own queue jobs.
Polling. Every answer has a weak ETag (and Cache-Control: private, no-cache). Send it back as If-None-Match and an unchanged board answers 304 with no body. Poll every 30 to 60 seconds; each call, 304 or not, counts against the rate limit.
Errors: 404 not_found (unknown branch), 422 (a date that isn't YYYY-MM-DD or doesn't exist, such as 2026-02-30).
#GET /v1/office/dispatch/live
The live part of the board, light enough to poll every 30 to 60 seconds for a map: techs[] (personId, displayName, status off / idle / en_route / on_site, laneStatus, currentJobId, nextJobId, position { lat, lng, accuracy, heading, speed, recordedAt }) and jobs[] (every visible stop: jobId, personId, externalRefs, clientCompanyId, stage, start, end, local, eta, minutesLate, risk, reason, basis, enRouteAt, arrivedAt, completedAt, plannedOrder, actualOrder). Same branch and client filters, scoping, privacy and ETag as GET /v1/office/dispatch; date defaults to today in the company's zone. Pushed instead: webhooks dispatch.position and job.risk_changed. Full field list: platforms.md.
curl -s "$CREWLINK/v1/office/dispatch/live" -H "Authorization: Bearer $KEY"#GET /v1/office/dispatch/routes
Where each working tech is and the route they are on, for a live map (the office's Map view on Dispatch). Poll every 15 to 30 seconds with If-None-Match.
| Query | |
|---|---|
date |
Local date YYYY-MM-DD; default today in the company's zone |
branch |
A branch id (404 not_found for another company's) |
client |
A client company id (or none): only techs with a stop for that client that day. Their whole route still shows (the tech drives to every stop) |
techIds |
Only these techs: person ids, comma separated (3,7) |
curl -s "$CREWLINK/v1/office/dispatch/routes" -H "Authorization: Bearer $KEY" -H 'If-None-Match: W/"Zq1f..."'Per tech (techs[]): personId, displayName, status (off, idle, en_route, on_site), laneStatus (the board's risk dot: ok, tight, late, on_job, idle), currentJobId, nextJobId, position { lat, lng, accuracy, heading, speed, recordedAt }, heading, positionState, navigating, route, routeSource, progressMeters, legs[] and stops[].
route: the driving line the tech is on now, a GeoJSONLineString([lng, lat]). When the tech is navigating in the field app (navigating: true: they opened in-app directions to their next job, tapped On my way, and the route is less than 90 minutes old) it is the route their phone is following, then the planned legs after that job (routeSource: "navigation"). Otherwise it is the planned route ("planned") from their position (or the stop they're at or last left, or where their day starts) through their remaining stops in booked order.nullwhen nothing is left.progressMeters: how far alongroutethe tech is (draw what is behind as driven).legs[]: one per remaining stop:from(position,origin= where the day starts, orjob),fromJobId,toJobId,distanceMetersanddurationSeconds(from a position: what is left),eta(UTC, whole minutes; chained: the later of arriving and the start, plus the booked length, plus the drive), signedminutesLate,provider(mapboxwith live traffic,osrm, orstraight) andendIndex(where inroute.coordinatesthe leg ends).stops[]: the tech's booked stops that day in order (order), withstart,end,local,stage,state,reached(done, on site or arrived),eta,risk{ level, minutesLate },customer(name only),service,client,address{ full, lat, lng },externalRefs.positionStatesays why there is or isn't a position:live(a fix in the last 15 minutes),stale(older),none(location off: no fix since they came on the clock),off_clock,hidden(this caller may not see it now);nullon another day.mapStyle: CrewLink's map style and public map token (as on the field app's route), ornull(draw without tiles). Alwaysnullfor platform keys.
Privacy. A position only while the tech is on the clock (now and when the fix was taken: from 30 minutes before their first working window or visit to 30 minutes after their last), for every caller including the office; never off the clock. Platform keys need read; names and positions by the grant (who sees a position), no customer phone, and no leg from where the day starts (a home) for a short-names grant. People with a team or own scope see only techs with a stop of theirs that day, only those stops, a position only while the tech's current or next job is theirs, and the route only up to the first stop they can't see.
Routing cost. Routes come from the same provider as the field app's directions (Mapbox driving-traffic, else OSRM) and are cached: a leg between two fixed points for 30 minutes, the leg from a moving tech for 3 minutes while they stay within 300 m of it (their progress is measured along it, not re-routed). One read makes at most 6 provider calls and all reads together at most 60 a minute; a leg over that budget, or with no routing configured, is a straight line with the planned drive time (provider: "straight") until a later read fills it in. A steady poll costs no provider calls.
Errors: 404 not_found (unknown branch), 422 (a bad date or techIds).
#Team
People on the schedule. Add someone with POST /v1/office/team; to take someone off the schedule, set active: false. To let a person sign in to the field app, add a tech user for them with POST /v1/office/users.
Working hours (hours) are seven entries, Monday first. Each is [start, end] in minutes after local midnight, or null for a day off. [480, 1020] is 8:00 to 17:00. start and end are whole minutes from 0 to 1440, start before end; anything else is 422.
Several windows a day (availability): seven lists, Monday first, each a list of [start, end] windows (at most 6 a day, not overlapping; [] for a day off). [[480, 720], [780, 1020]] is 8 to 12 and 1 to 5. A visit has to fit inside one window; the gaps are never offered. Send availability instead of hours to set it; hours then reads each day's envelope (first start to last end), so code that only knows hours keeps working. Sending hours again goes back to one window a day.
The tech's own changes. When the owner allows it (techsEditHours in the pay settings, on by default), a tech can change their own hours and home address in the field app (field-app.md). Their change takes effect the next day, so the day already booked stays put; until then it shows as pendingChange. Changing the same thing here replaces it.
Designation (designation) is the tax designation: w2 (a W-2 employee) or 1099 (a 1099 contractor). It is the same as kind (employee / contractor); scheduling treats both the same. The pay report filters by it.
Max travel distance (maxTravelMiles): optional. The person is never offered a job farther than this from home (straight line; from their branch when their day starts there, see startsFrom), on top of their branch's area. Without a branch it is also their service radius.
Tax id (taxId): an SSN or EIN, stored encrypted (AES-256-GCM) and only ever shown masked (•••••6789).
Skills (skills) is a bit set: each bit is one skill your company defines (for example bit 0 = maintenance, bit 1 = repair, bit 2 = installs). A person can do a service when they have every skill bit the service needs. 7 (binary 111) has all three of those; 3 (011) can't do installs.
Service areas belong to branches: each person serves the area of their branch (branchId). Someone without a branch serves a radius around their home (around their base on a base day): their maxTravelMiles, else the company's defaultTechRadiusMiles (settings), else 40 km; serviceRadiusMiles on the person says which. A national company whose techs work from home sets defaultTechRadiusMiles (for example 150). Addresses outside every area get no online times (outsideArea); see Out-of-area requests and trips. Every automatic path keeps the same rule: recurring visits outside every area are queued for the office instead of booked (recurring), and CrewConnect work outside the partner's areas is refused (out_of_area, crewconnect.md). Only the office books past an area on purpose: accepting an out-of-area request, or naming techIds.
Where the day starts (startsFrom on the person). The first job of a day is offered only when the shift start, plus the drive from here at that time of day, plus the buffer, fits before it: the same check as every other leg, with nothing skipped. In order: their home (home, address); someone added with only a branchId starts from the branch's location (kind: "branch"; they move with the branch when an owner moves it); a home that isn't a real place (0,0) falls back to the branch; with neither, kind is none and they are never offered, auto-booked or picked by CrewLink, and naming them (techIds) is refused with "has no start location". A trip or base day starts at its base instead. The company itself has no location and is never a start. More in engine.md.
Time zone. A person's hours and day are on their own clock (timezone): the zone set on them (ownTimezone, timezone on POST or PATCH), else their branch's (the company's when the branch has none of its own), else, without a branch, the zone of their home (from its coordinates; an offline map), else the company's. [480, 1020] for a Denver tech is 8:00 to 17:00 Denver time, and for a Baltimore tech of a Chicago company without a branch it is 8:00 to 17:00 Eastern. Zones that keep the same clock are one (a Louisville home reads America/New_York). A job is planned on the clock of its address (a branch whose area holds it gives its own zone, else the address's own; if only people on another clock cover it, the nearest one's) and only people on that clock are assigned to it. When someone's zone changes (their timezone, a home in another zone, another branch), their booked jobs and time off from now on are re-expressed on the new clock keeping every instant.
#The person object
{
"id": 1,
"name": "Maria Gomez",
"kind": "employee",
"branch": "North",
"branchId": "6a1f3c2e-9b0d-4e57-8c21-3f5a7d9e1b42",
"designation": "w2",
"home": { "lat": 41.93, "lng": -87.7 },
"address": { "line1": "2150 W Roscoe St", "city": "Chicago", "region": "IL", "postalCode": "60618", "country": "US" },
"skills": 7,
"hours": [[480, 1020], [480, 1020], [480, 1020], [480, 1020], [480, 1020], null, null],
"availability": [[[480, 720], [780, 1020]], [[480, 1020]], [[480, 1020]], [[480, 1020]], [[480, 1020]], [], []],
"pendingChange": null,
"maxTravelMiles": 30,
"verified": true,
"taxId": "•••••6789",
"phone": "+13125550123",
"email": "maria@northline.example",
"hourlyCostCents": 3800,
"active": true,
"timezone": "America/Chicago",
"ownTimezone": null,
"hoursSource": null,
"serviceRadiusMiles": null,
"externalRefs": { "acme": "tech_42" },
"availabilityManagedBy": null,
"availabilityManagedByName": null,
"ownAvailabilityManagedBy": null
}| Field | Notes |
|---|---|
kind |
employee or contractor |
designation |
w2 or 1099: the tax designation, the same as kind |
externalRefs |
Other systems' ids for this person, by source (see POST /v1/office/team) |
test |
true: a test person: only takes jobs of test clients, left out of KPIs, Wins and pay reports by default |
skillsAll |
true: qualified for every active service, now and later (skills: "all"); skills follows the services |
branch, branchId |
The branch's name and id; both null for someone not tied to a branch |
home |
Where their day starts and ends |
address |
Their home address (line1, line2, city, region, postalCode, country), or null |
hours |
Monday first: each day's [start, end], or null off. With several windows, the envelope |
availability |
Monday first: every window worked that day; [] off |
availabilityManagedBy, availabilityManagedByName |
The integration that owns their hours, time off, home, radius and bases (e.g. acme, "Acme"), or null: edited here (Availability managed by an integration) |
ownAvailabilityManagedBy |
Their own setting: null (follow the company), crewlink or a platform slug |
pendingChange |
The tech's own change waiting for its day: { from, availability, address, home } (null parts unchanged), or null |
maxTravelMiles |
Their personal travel limit, or null |
verified |
The office checked their identity and paperwork |
taxId |
Masked (last 4 only), or null |
phone |
Their mobile, or null. Never shown to customers |
email |
Their email (lower case), or null: where invite-all sends their invite. Platform keys see it too; it's never on a customer page. Their sign-in email is on their user |
hourlyCostCents |
Loaded labor cost per hour (wage plus burden), used for job P&L. null uses the default ($35.00) |
timezone |
The IANA zone of their hours, day and jobs: ownTimezone, else their branch's, else (no branch) their home's, else the company's |
ownTimezone |
The zone set on them, or null (it follows their branch or home) |
hoursSource |
default_recent_activity: CrewLink gave them the default week (Monday to Friday 8:00 to 17:00 on their clock) because they had no hours and completed a job in the last 3 months; any hours set later replace it. null: set by you, the API or the tech |
serviceRadiusMiles |
Without a branch: how far from home they are offered work, in miles (maxTravelMiles, else the company's defaultTechRadiusMiles, else 40 km). null with a branch (the branch's areaKm) |
startsFrom |
Where their day starts on a normal day: { kind, lat, lng, address, branchId, branchName, text }. kind home (their home), branch (their branch's location: added without a home of their own, or their home isn't a real place) or none (neither: never offered until a home or branch location is set). text says it in words: "Home (4499 N Melvina Ave, Chicago, IL)", "Branch North (4544 N Lincoln Ave, Chicago, IL 60625)". See Where the day starts under Team |
#GET /v1/office/team
Everyone on the team, active or not. When an integration owns the team's hours (availabilityManagedBy), see Availability managed by an integration.
| Query | Notes |
|---|---|
email |
Only the person with this email (their own email, or their sign-in user's; case doesn't matter) |
externalSource, externalRef |
Only people with that external id (either alone works too) |
Response 200: { "people": [ <person>, ... ] }. A platform key sees names as first name and last initial (unless its grant says full names).
#GET /v1/office/team/{id}
One person: the same object as an item of GET /v1/office/team, with the same rules (your company's people only; a platform key sees "First L." unless its grant says full). Changes the tech made in the field app that are due are applied first, as on the list.
Path: id (integer) person id. Response 200: <person>. Errors: 404 not_found (no one with that id on your team, including anyone at another company). People with a team or own scope may call it, like the list.
curl -s "$CREWLINK/v1/office/team/57" -H "Authorization: Bearer $KEY"#Availability managed by an integration
A company can let an integration (a platform) own its technicians' availability: weekly hours, time off, home, zone, travel radius and bases. availabilityManagedBy is null (managed here, the default) or the platform's slug. acme-crew is managed by acme. While it is set, only that platform's key changes those fields; everyone else (owners, field managers, dispatchers, the company API key, other platforms, and techs in the field app) gets:
{ "error": "managed_by_integration", "message": "Hours for this team are managed in Acme. Change them in Acme.", "managedBy": "acme", "managedByName": "Acme" }on PUT /v1/office/team/{id}/availability, PATCH /v1/office/team/{id} with hours, availability, homeLat/homeLng, address, timezone or maxTravelMiles (other fields still work), POST and DELETE /v1/office/blocks, POST and DELETE /v1/office/team/{id}/bases and the field app's PATCH /v1/tech/profile. Busy time from linked Google or Outlook calendars is still synced and can still be removed. Adding people and booking are unchanged.
- Emergency override: an owner (signed in, or the company API key; never a platform) adds
"force": trueto the body (?force=trueon theDELETEs). It is applied, recorded in the audit below, and its webhooks carry"forced": true.forcefrom anyone else is403 forbidden. - Per person:
PATCH /v1/office/team/{id}{ "availabilityManagedBy": "crewlink" }(owner) manages one person here although the company's is an integration; a platform slug puts one person under that platform;nullfollows the company. Team reads carryavailabilityManagedBy(the result),availabilityManagedByNameandownAvailabilityManagedBy(the person's own setting). - The web app shows those fields read-only, with "Hours, time off and home are managed in Acme. Change them there."
#GET /v1/office/availability-management
{ "availabilityManagedBy": "acme", "availabilityManagedByName": "Acme", "people": [{ "personId": 61, "availabilityManagedBy": "crewlink" }],
"overrides": [{ "at": "2026-10-12T16:02:00.000Z", "action": "availability_force", "actor": "user:2b7c...", "personId": 57, "method": "PUT", "path": "/v1/office/team/57/availability", "detail": { "what": "hours", "managedBy": "acme" } }] }people: those whose own setting isn't "follow the company". overrides (owners only, [] otherwise): the latest 50 audit rows, newest first; action is availability_force (an owner overrode the integration) or availability_managed_by (who owns it changed); actor is user:<id>, api_key, admin:<id> or platform:<slug>.
#PUT /v1/office/availability-management
Body { "managedBy": "acme" } or { "managedBy": null }. Owners only (403 for field managers and dispatchers). An owner may set any platform with a live grant on the company (404 not_found otherwise) or null; a platform key may claim it for itself or release it when it owns it (403 for another slug, 409 managed_by_integration when another integration owns it). Recorded in the audit. Response 200: as GET. Ops set it for acme-crew with cli.ts platform-availability acme acme-crew on.
#POST /v1/office/team
Add a person. They're offered for work from now on: within their branch's service area, during their hours, for the services their skills cover. There is no default schedule: hours and availability are optional, and someone added without either has no working hours (every day null). A tech with no hours is never offered until hours are set (PATCH /v1/office/team/{id} or PUT /v1/office/team/{id}/availability).
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | 1 to 200 characters |
homeLat, homeLng |
number | yes, unless branchId |
Their home (where the day starts and ends). Left out with a branchId: the branch's location. 422 with neither, or with only one of them |
address |
object | no | Their home address: line1 and city required; line2, region, postalCode, country optional |
hours |
array of 7 | no | Monday first; each [start, end] minutes after midnight, or null for a day off. Send this or availability; neither: no hours (never offered until set) |
availability |
array of 7 | no | Monday first; each a list of [start, end] windows, [] for a day off |
branchId |
string | no | One of your branches |
skills |
integer or "all" |
no | Bit set, >= 0, or "all": every active service, now and later (skillsAll: true on reads; skills is kept equal to every active service's bits when services are added, changed or turned off). Default "all" for platform keys (so the people they add are bookable at once), 1 otherwise |
kind |
string | no | employee (default) or contractor |
designation |
string | no | w2 or 1099; wins over kind |
phone |
string | no | 7 to 40 characters. Their mobile, for masked calls. Never shown to customers |
email |
string | no | Their email, stored lower case: where invite-all sends their field-app invite |
hourlyCostCents |
integer | no | Loaded labor cost per hour |
maxTravelMiles |
number | no | 1 to 3000. Without a branch, also their service radius |
timezone |
string | no | IANA zone of their hours (America/New_York). Default: their branch's, else their home's, else the company's. 422 for an unknown zone. Platform keys may send it |
verified |
boolean | no | Default false |
taxId |
string | no | SSN or EIN, 4 to 20 letters and digits (spaces and dashes are dropped) |
test |
boolean | no | true: a test person from the start (only offered or booked for a test client's jobs), as PATCH { "test": true } does. Default false. Platform keys may send it |
externalRefs |
object | no | Other systems' ids for them, { "<source>": "<id>" }. Each (source, id) is unique in your company (409 duplicate_external_ref with personId) |
externalRef, externalSource |
string | no | Shorthand for one entry; the source defaults to api (a platform key: its slug) |
Sends person.updated.
curl -s -X POST "$CREWLINK/v1/office/team" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Sam Rivera","branchId":"6a1f3c2e-9b0d-4e57-8c21-3f5a7d9e1b42","homeLat":41.94,"homeLng":-87.69,"skills":3,
"hours":[[480,1020],[480,1020],[480,1020],[480,1020],[480,1020],null,null],"hourlyCostCents":4200}'Response 201: the person.
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No branch with that id in your company |
| 422 | invalid_request |
A field failed validation (e.g. hours not 7 entries, a day's start not before its end, overlapping windows, or a tax id that isn't one) |
#PATCH /v1/office/team/{id}
Change someone's details, branch, home address, hours, skills, active status, mobile number, labor cost, designation, tax id, max travel distance or verified. Send at least one field. A move is homeLat/homeLng with the new address (from address search); their day starts and ends there from now on.
New hours, skills, branch and home apply to times offered from now on. Jobs already booked stay where they are; the response lists the upcoming ones (today onward) that now fall outside the person's hours, so the office can move them with POST /v1/office/jobs/{id}/move. Setting active: false lists all of that person's upcoming jobs.
Path: id (integer) person id.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | no | 1 to 200 characters |
branchId |
string or null | no | One of your branches; null for none (they then cover their radius around home, on their home's clock) |
homeLat, homeLng |
number | no | Send both or neither |
address |
object or null | no | Home address (see POST) |
kind |
string | no | employee or contractor |
designation |
string | no | w2 or 1099 (the same as kind) |
hours |
array of 7 | no | Monday first; each [start, end] minutes after midnight, or null for a day off. One window a day |
availability |
array of 7 | no | Monday first; each a list of [start, end] windows, [] off. Several windows a day |
skills |
integer or "all" |
no | Bit set, >= 0 (turns skillsAll off), or "all": every active service, now and later |
active |
boolean | no | false takes them off the schedule: nothing new is offered or assigned. To also turn off their sign-in, use deactivate |
phone |
string | no | 7 to 40 characters. The tech's mobile, used for masked calls and texts with customers. Never shown to customers |
email |
string or null | no | Their email (stored lower case); null removes it |
hourlyCostCents |
integer or null | no | Loaded labor cost per hour; null uses the default |
maxTravelMiles |
number or null | no | Never offered work farther than this from home; null removes the limit |
timezone |
string or null | no | IANA zone of their hours; null: follow their branch, else their home. A new zone re-expresses their booked jobs and time off on the new clock (same instants) |
verified |
boolean | no | |
taxId |
string or null | no | SSN or EIN, stored encrypted, shown masked; null removes it |
test |
boolean | no | true: a test person: only offered or booked for jobs of a test client, never anyone else's, and left out of KPIs, the Wins leaderboard and pay reports by default. false: a real tech again. Jobs already booked stay put. Platform keys may set it |
availabilityManagedBy |
string or null | no | Who owns this person's availability: null follows the company, crewlink manages them here, or a platform slug with a live grant. Owners only; a platform key may set its own slug or null (more) |
force |
boolean | no | Owners only: change hours, availability, home, timezone or maxTravelMiles although an integration owns this person's availability (recorded) |
externalRefs |
object | no | Ids to add or replace by source; a null id removes that source |
externalRef, externalSource |
string | no | Shorthand for one entry |
curl -s -X PATCH "$CREWLINK/v1/office/team/1" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"hours":[[480,1020],[480,1020],null,[480,1020],[480,1020],null,null]}'Response 200:
{ "id": 1, "outsideHours": [1207], "externalRefs": {}, "timezone": "America/Chicago", "rezoned": { "jobs": 0, "blocks": 0 } }outsideHours is a list of job ids, empty when nothing is affected. timezone is their zone now; rezoned counts the booked jobs and time-off blocks moved onto a new clock (zero unless their zone changed). Sends person.updated, and availability.updated when hours was sent.
Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
force or availabilityManagedBy from someone who isn't an owner |
| 404 | not_found |
No one with that id on your team, or no branch with that branchId |
| 409 | duplicate_external_ref |
One of the ids is on someone else (personId) |
| 409 | managed_by_integration |
Hours, home, zone or radius sent while an integration owns this person's availability (managedBy, managedByName) |
| 422 | invalid_request |
No field sent, only one of homeLat/homeLng, or a field failed validation |
#Documents
Files about a person: insurance certificates, licences, the W-9, ID. They go straight to storage (the API only keeps the key), and the office sees them through a link that works for 10 minutes. Link one to a credential with documentId so its expiry shows with it.
The document object:
{ "id": "1b0e4c1a-5f07-4a4e-9a51-21e7b8f6c9d0", "personId": 1, "kind": "insurance", "name": "COI 2026.pdf", "contentType": "application/pdf", "size": 120400, "expiresOn": "2027-01-01", "status": "uploaded", "createdAt": "2026-10-12T15:30:00.000Z", "url": "https://files.example.com/...?X-Amz-Signature=..." }kind is insurance, license, w9, certification, id or other. status is pending until marked uploaded; url is null until then.
#GET /v1/office/team/{id}/documents
Response 200: { "documents": [ <document>, ... ] }, newest first. 404 not_found for someone not on your team.
#POST /v1/office/team/{id}/documents
Body: kind, name (1 to 200), contentType (application/pdf, image/jpeg, image/png, image/heic or image/webp), size (bytes, at most 25 MB), expiresOn (optional, YYYY-MM-DD).
Response 201: { "document": <document>, "uploadUrl": "..." }. PUT the file to uploadUrl with the same Content-Type within 15 minutes (storage refuses another type), then mark it uploaded.
#POST /v1/office/team/{id}/documents/{docId}/uploaded
Response 200: the document, status: "uploaded" with a url, and size set to the file's real size. CrewLink checks storage first: 409 not_uploaded when nothing is there yet (finish the PUT, then confirm again); 422 upload_mismatch when the file is bigger than the size you declared or than 25 MB, or isn't the declared type (the file and the pending document are removed: add it again); 502 storage_unavailable when storage didn't answer. Confirming twice is harmless.
#DELETE /v1/office/team/{id}/documents/{docId}
Response 200: { "deleted": true }. The file is deleted from storage. Credentials linked to it keep their details and lose the link.
#POST /v1/office/team/{id}/deactivate
Owner only. Takes them off the schedule (nothing new is offered or assigned) and turns off their field-app sign-in: every session ends and their passkeys are removed. Jobs already booked stay put.
Response 200: { "id": 7, "active": false, "signInsTurnedOff": 1, "upcoming": [1207, 1211] }: upcoming lists their booked jobs from today on, to move. Pay already earned stays in the pay report.
#POST /v1/office/team/{id}/reactivate
Owner only. Puts them back on the schedule. Their sign-in stays off; turn it back on with POST /v1/office/users/{id}/reactivate, so plan login limits apply as usual.
Response 200: { "id": 7, "active": true, "signIns": [{ "userId": "…", "active": false }] }.
#POST /v1/office/team/{id}/reset-sign-in
Owner only. For a lost phone or a shared device: signs every login of theirs out everywhere, removes their passkeys and replaces their token (the old one stops working). They sign in again with a code emailed to them; there are no passwords.
Response 200: { "id": 7, "signInsReset": 1 }.
#PUT /v1/office/team/{id}/availability
Replace someone's whole week. Body { "hours": [7 days], "timezone": "<optional>" }, Monday first; each day off (null or []), one window [start, end] or several [[start, end], ...], minutes after local midnight in the person's zone. Idempotent: the same week again changes nothing (changed: false) and sends nothing. Several windows a day are stored as sent (the person's availability; hours is each day's envelope) and merged is always []; the answer is the stored week. It replaces a change the tech made in the field app that hasn't taken effect yet. timezone, when sent, must be the person's (422 timezone_mismatch). Booked jobs stay; outsideHours lists the ones now outside. Sends availability.updated and person.updated when the week changed. force: true (owners): see Availability managed by an integration.
Response 200: { "personId": 57, "timezone": "America/Chicago", "availability": [[[480,720],[780,1020]], ...], "hours": [[480,1020], ...], "merged": [], "changed": true, "outsideHours": [] }. availability is the stored week in the request's shape (every day a list of windows, [] off): compare it with what you sent. hours is each day's envelope (first start to last end, null off), kept for code that reads one window a day; team reads carry both. Errors: 404 not_found, 409 managed_by_integration, 422 invalid_request (not 7 days, a window with start not before end, overlapping windows), 422 timezone_mismatch.
#Bases: POST, GET and DELETE /v1/office/team/{id}/bases
A base is where someone starts and ends their days for a while (lodging, a temporary address) instead of home; open times and routing use it on those days.
POST{ from, to, lat, lng, address?, reason?, externalRefs?, force? }:from/tolocal dates, inclusive, up to 366 days;lat/lngmay come fromaddressparts.201: the base{ id, personId, from, to, lat, lng, address, addressParts, reason, externalRefs, tripId, createdAt }.409 trip_conflict(tripId) when a booked trip covers any of those days,409 base_conflict(baseId) when another base does,409 duplicate_external_ref(baseId) when one of theexternalRefsids is already on a base of your company (nothing is created).GET ?from=&to=(default today and the 90 days after):{ "bases": [ ... ] }, the ones overlapping.DELETE /{baseId}:{ "id": "...", "deleted": true }. A booked trip writes its own base (tripIdset); removing that one is409 trip_base.
Each change sends person.base.updated (with source, platform, forced). POST and DELETE answer 409 managed_by_integration when an integration owns the person's availability (more; owners: force: true in the body, ?force=true on DELETE).
#Branches and services
Branches are service areas: a location and a radius. People in a branch work within areaKm of its location, and its people without a home of their own start their day there. A branch without a usable location (located: false) covers nothing until an owner sets one. A branch in another time zone or country has its own timezone and country: its techs' hours and days, and the jobs it serves, run on its clock; its phone numbers get texts and address search covers its country. An address belongs to the branch whose area contains it (the nearest such branch), else to the nearest branch.
Services are what customers book. Active ones show on the booking page and to CrewConnect senders.
#The branch object
{ "id": "9c2d...", "name": "Denver", "lat": 39.7392, "lng": -104.9903, "address": "1437 Bannock St, Denver, CO 80202", "located": true, "areaKm": 40, "timezone": "America/Denver", "ownTimezone": "America/Denver", "country": "US", "ownCountry": null }| Field | Notes |
|---|---|
lat, lng |
Where it is: the centre of its area |
address |
That place as one line (from address search), or null when only coordinates are on file (Settings then shows "No address on file" and the coordinates) |
located |
false when it has no usable location (0,0): its area covers nothing and nobody can start their day there. Set one with PATCH |
timezone |
The zone its jobs and people use: ownTimezone, else the company's |
ownTimezone |
The zone set on the branch, or null (it follows the company) |
country |
ISO 3166-1 alpha-2: ownCountry, else the company's |
ownCountry |
The country set on the branch, or null |
#GET /v1/office/branches
Response 200: { "branches": [ <branch>, ... ] }, sorted by name.
#POST /v1/office/branches
Add a branch.
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | 1 to 200 characters |
lat, lng |
number | yes | Where it is: the center of the area |
address |
string | no | That place as one line (pick it from address search, which gives lat/lng too) |
areaKm |
number | yes | Radius, 1 to 500 km |
timezone |
string or null | no | IANA zone (America/Denver, Europe/London) when it isn't the company's |
country |
string or null | no | ISO 3166-1 alpha-2 (GB) when it isn't the company's |
curl -s -X POST "$CREWLINK/v1/office/branches" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Denver","lat":39.7392,"lng":-104.9903,"areaKm":40,"timezone":"America/Denver"}'Response 201: the branch. Put people in it with branchId on POST /v1/office/team or PATCH /v1/office/team/{id}. Errors: 422 invalid_request for an unknown zone or country.
#PATCH /v1/office/branches/{id}
Change a branch. Owner only. Send only what changes: name, lat and lng together (with address, its line from address search; address: null clears the line and keeps the coordinates), areaKm, timezone (null: back to the company's), country (null: the company's).
A new location moves the centre of its area. Its people who start from the branch (added without a home of their own: their home is a copy of the branch's old spot and they have no address) move with it. Booked jobs stay as they are.
A new time zone keeps every booked instant: the branch's people's jobs from now on (booked or held) and their time off are re-expressed in the new zone, so a visit at 9:00 Chicago time shows as 8:00 Denver time; customers are not texted, because nothing moved. Working hours stay as typed (9 to 5 means 9 to 5 on the new clock).
curl -s -X PATCH "$CREWLINK/v1/office/branches/9c2d..." -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"timezone":"America/Denver"}'Response 200: the branch plus "rezoned": { "jobs": 12, "blocks": 2 } (what was re-expressed).
Errors: 403 forbidden (not an owner), 404 not_found (not your branch), 422 invalid_request (unknown zone or country, lat without lng, an address without lat/lng for a branch that has no location yet, nothing to change).
#The service object
{
"id": 3,
"name": "New system install",
"minutes": 150,
"skills": 4,
"peopleNeeded": 2,
"days": 1,
"priceCents": 450000,
"depositCents": 50000,
"currency": "USD",
"active": true,
"basePayCents": 12000,
"paySplit": "split",
"category": "residential",
"daylightOnly": false
}| Field | Notes |
|---|---|
basePayCents |
What the tech is paid for each completed job of this service (technician pay), or null for none. Owner only |
paySplit |
A job more than one person works: split (the base pay is shared equally, the default) or each (everyone gets all of it). Owner only |
category |
residential, commercial, other, or null (not set). Shown in the booking-page catalog; integrations branch their flows on it. Anything else is 422 |
minutes |
Default length of one person's visit, 5 to 1440. CrewLink blocks 20% more until it has learned each person's own time |
skills |
Bit set: people need every bit to do it |
peopleNeeded |
People who go together, 1 to 10 |
days |
Consecutive days, same people and start time each day, 1 to 14 |
priceCents |
List price, or null for no set price |
depositCents |
Taken when the customer books online; 0 for none. Never more than the price |
currency |
ISO 4217 code, e.g. USD |
active |
false: no longer offered on the booking page or to CrewConnect senders, and recurring visits of it aren't booked. Jobs already booked stay |
daylightOnly |
true: offered and booked only when the whole visit is between sunrise and sunset at the job's address on that date (Daylight only); 409 outside_daylight otherwise. Owner only. Default false |
#GET /v1/office/services
All your services, turned-off ones included. Response 200: { "services": [ <service>, ... ] }.
#POST /v1/office/services
Add a service. Body: the fields of the service object except id. name and minutes are required; the defaults are skills: 1, peopleNeeded: 1, days: 1, priceCents: null, depositCents: 0, currency: "USD", active: true, daylightOnly: false.
curl -s -X POST "$CREWLINK/v1/office/services" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Duct cleaning","minutes":120,"peopleNeeded":2,"priceCents":34900,"depositCents":5000}'Response 201: the service. Errors: 422 invalid_request (a field failed validation, or depositCents is more than priceCents).
#Daylight only
A service with daylightOnly: true (owners set it, on POST or PATCH; Settings, Services in the web app) is offered and booked only when the whole visit is in daylight at the job's address on that date: the start at or after local sunrise, the end (the start plus the service's booked length: minutes plus 20%, rounded up to 5) at or before local sunset, on every day of a multi-day service. Sunrise and sunset are worked out from the address and date (the NOAA solar equations, to about a minute, in the address's time zone with daylight saving). Where the sun doesn't rise that day nothing is offered; where it doesn't set, the whole day counts.
- Offered times (booking page availability, POST /v1/office/availability, first-available, client share links, the intake queue's suggestions and pick-a-new-time choices) only include daylight starts.
- Holds, bookings and moves outside it (booking page, share link and key holds, a share link booking of a held time,
POST /v1/office/jobs, move, a customer's own reschedule) get409 outside_daylightwith a message that says the times, e.g.Residential survey is booked in daylight only: on 2026-12-14 at this address the sun rises at 7:12 AM and sets at 4:20 PM, so a 75-minute visit has to start between 7:12 AM and 3:05 PM.WithallowQueueonPOST /v1/office/jobsthe request goes to the intake queue instead. - An owner can override it:
allowOutsideHours: trueonPOST /v1/office/jobsor a move books a daylight-only service outside daylight (working hours and every other check still apply). Dispatchers and field managers get403 owner_onlyfor it. On any other serviceallowOutsideHoursstays for imports only. - Exempt: imports (
source: "import") and history written withallowPast.
#PATCH /v1/office/services/{id}
Change any of the fields, or turn the service off with active: false. Send at least one field. Changes apply to times offered and jobs booked from now on; a new basePayCents applies to jobs completed from now on.
Response 200: the service. Errors: 403 forbidden (a dispatcher sent basePayCents, paySplit or daylightOnly), 404 not_found (no service with that id in your company), 422 invalid_request (no field sent, a field failed validation, or the deposit would be more than the price).
#Time off and blocks
A block takes time off someone's calendar: time off, training, a personal appointment. Nothing new is offered or assigned inside it. Busy time from a tech's linked Google or Outlook calendar also shows up as blocks (with source google or outlook) on the schedule.
#POST /v1/office/blocks
Block time for one person. Jobs already booked inside the block are not moved: they come back as conflicts for the office to move or reassign.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
personId |
integer | yes | Someone on your team |
start |
string | yes | ISO datetime with offset |
end |
string | yes | ISO datetime with offset; after start, at most 60 days later |
reason |
string | no | Up to 200 characters. Shown on the board |
externalRefs |
object | no | Another system's ids for this block ({ "<source>": "<id>" }), so a sync that sends it again gets 409 duplicate_external_ref with blockId instead of a second block |
externalRef, externalSource |
string | no | Shorthand for one entry; the source defaults to api (a platform key: its slug) |
force |
boolean | no | Owners only: add it although an integration owns this person's availability (recorded; more) |
curl -s -X POST "$CREWLINK/v1/office/blocks" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"personId":1,"start":"2026-11-03T14:00:00Z","end":"2026-11-03T23:00:00Z","reason":"Dentist"}'Response 201:
{
"id": 17,
"personId": 1,
"start": "2026-11-03T08:00",
"end": "2026-11-03T17:00",
"timezone": "America/Chicago",
"reason": "Dentist",
"conflicts": [],
"externalRefs": {}
}start and end are the person's wall clock (their branch's zone, in timezone). conflicts lists booked job ids that overlap the block.
Sends the block.created webhook event (with source, platform and forced: who added it).
Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
force from someone who isn't an owner |
| 404 | not_found |
No one with that id on your team |
| 409 | duplicate_external_ref |
A block already has that external id (blockId); nothing was created |
| 409 | managed_by_integration |
An integration owns this person's availability (managedBy, managedByName) |
| 422 | invalid_request |
end not after start, longer than 60 days, or a field failed validation |
#DELETE /v1/office/blocks/{id}
Remove a block. Its times become bookable again. This works on calendar-synced blocks too, but the next calendar sync puts them back while the event is still in the person's calendar.
Path: id (integer) block id.
Response 200:
{ "id": 17, "deleted": true }Query: force=true (owners only) removes a block although an integration owns the person's availability (recorded). Busy time from a linked calendar (source google or outlook) can always be removed.
Sends the block.deleted webhook event (with the block's externalRefs, blockSource, and source, platform, forced). Errors: 404 not_found (no block with that id in your company), 403 forbidden (force from someone who isn't an owner), 409 managed_by_integration.
#GET /v1/office/blocks
Blocked time that overlaps a date range, earliest first: blocks made here and busy time imported from linked calendars, with the person's name. Use it to list time off and remove some with DELETE.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
from |
string | no | YYYY-MM-DD, company-local. Default today |
to |
string | no | YYYY-MM-DD, inclusive. Default 30 days after from. At most 92 days from from |
personId |
integer | no | Only this person |
externalSource, externalRef |
string | no | Blocks with that external id, on any date (from/to are ignored) |
curl -s "$CREWLINK/v1/office/blocks?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"Response 200:
{
"blocks": [
{ "id": 17, "personId": 1, "person": "Maria Gomez", "start": "2026-11-03T08:00", "end": "2026-11-03T17:00", "reason": "Dentist", "source": "office" },
{ "id": 21, "personId": 2, "person": "James Okafor", "start": "2026-11-04T09:00", "end": "2026-11-04T10:00", "reason": null, "source": "google" }
]
}| Field | Notes |
|---|---|
start, end |
Local wall clock. A block that starts before from or ends after to is listed whole |
externalRefs |
Other systems' ids for the block, by source |
source |
office (made through this API or the web app), google or outlook (busy time from a linked calendar; deleting one lasts until the next sync while the event is still in that calendar) |
Range and paging. At most 92 days per call (from to to, inclusive). A longer range, or to before from, is 422 invalid_request with "message": "Up to 92 days, from on or before to.". There is no cursor or page size: every block in the range comes back in one answer, ordered by start then id. To read a longer stretch, call again with the next window (from = the day after the previous to); a block that spans two windows is listed in both, so de-duplicate by id. externalSource/externalRef lookups have no range limit.
Errors: 422 invalid_request (to before from, or more than 92 days).
#Office jobs
Booking, moving and cancelling from the office uses the same safeguards as the booking page: CrewLink locks the affected days, rechecks the live schedule (everyone's other jobs, blocks, linked calendars and the drive between jobs at that hour) and only then writes. Two dispatchers can't double-book the same tech.
The customer is texted for every change (confirmation with their manage link, then a reminder; a new time; a cancellation). Texts go to mobile numbers in your company's countries (yours and every branch's): a US company texts US and Canadian numbers, one with a London branch UK numbers too. A number typed without + and a country code is read the way the job's country writes it (020 7946 0958 at a London job). The texts show the time on the clock of the branch serving the job. The customer-facing manage link is described in booking-page.md.
#POST /v1/office/first-available
Every start time a qualified tech can actually make at an address (their other jobs, calendar, hours and the drive at that hour), with who CrewLink would send, earliest first. This is the office's "first available" view, like the old app's schedule screen: unlike the booking page's availability (and POST /v1/office/availability, the same answer with a key) it lists every feasible start (the booking page hides starts whose added driving is more than 20 minutes worse than the best that day) and has no one-hour lead time (anything from now on). Times are on the clock of the branch serving the address.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes | |
lat, lng |
number | yes | The address |
from |
string | no | First local date YYYY-MM-DD; default today |
days |
integer | no | 1 to 14, default 1 |
stepMinutes |
integer | no | The grid of local minutes starts lie on: 1, 5, 15 (default), 30 or 60. 60 gives whole hours only (08:00, 09:00), 1 any minute (08:07). Every start is still one a tech can make, drive and other jobs included, and the same start has the same techs whatever the step. Anything else is 422 invalid_request |
curl -s -X POST "$CREWLINK/v1/office/first-available" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"serviceId": 3, "lat": 41.955, "lng": -87.675, "from": "2026-11-16"}'Response 200:
{
"timezone": "America/Chicago",
"slots": [
{ "start": "2026-11-16T14:15:00.000Z", "local": "2026-11-16T08:15", "techs": [{ "id": 1, "name": "Maria Gomez" }] }
],
"firstAvailable": { "start": "2026-11-16T14:15:00.000Z", "local": "2026-11-16T08:15", "techs": [{ "id": 1, "name": "Maria Gomez" }] },
"outsideArea": false
}techs is who would go (as many as the service needs, least added driving first; a platform key sees first name and last initial); book the time with POST /v1/office/jobs, which rechecks it under a lock. outsideArea is true when no one's service area covers the address. Errors: 404 unknown service.
#POST /v1/office/jobs
Book a job for a phone-in or walk-in customer, or for an integration. Unlike the booking page there's no hold step and no one-hour lead time: any time from now on works if someone can make it. CrewLink picks the best qualified tech(s) for that start from the people serving the address; the job is on the address's clock (timezone in the answer; with techIds, theirs). Or book a time you held with POST /v1/office/holds (or the booking page's holds): send holdId and holdSecret instead of serviceId, the time and the location; the hold's service, time, place and crew stand.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes, unless booking a hold | One of your active services |
lat |
number | yes, unless booking a hold or address has it |
-90 to 90 |
lng |
number | yes, unless booking a hold or address has it |
-180 to 180 |
start |
string | one of, unless booking a hold | ISO datetime with offset; not in the past |
local |
string | one of, unless booking a hold | Instead of start: the wall clock where the job is, YYYY-MM-DDTHH:mm, read in the zone of the branch serving the address |
holdId, holdSecret |
integer, string | to book a hold | From the hold. 404 when it's unknown or the secret is wrong, 410 hold_expired when it ran out |
customer.name |
string | yes | 1 to 200 characters |
customer.phone |
string | yes, except imports and contact |
7 to 40 characters. May be left out with source: "import" or contact; then no text is ever sent for the job |
contact.kind |
string | no | commercial (a business site) or office (reached through an office): the customer isn't a homeowner on a mobile, so customer.phone may be left out |
customer.email |
string | no | A valid email |
address |
string or object | no | One line (up to 300 characters), or its parts: { line1, line2, city, region, postcode, country, lat, lng } (line1 required, country two letters). Parts are kept as addressParts and joined into one line for texts and the board; their lat/lng are used when the top-level ones are missing |
notes |
string | no | Up to 2000 characters. Shown to the tech |
flexible |
boolean | no | Default true. false pins the job: a same-day emergency will never move it |
allowQueue |
boolean | no | Default false. true: when nobody can take it at that time (or the time has passed), file it in the intake queue instead of answering 409. Answers 202 |
externalRefs |
object | no | Your ids for the job by source, e.g. { "crm": "A-1001" }, up to 10. Each (source, id) is unique in your company among jobs that aren't cancelled: one already on another live job is 409 duplicate_external_ref with that jobId; one held only by cancelled jobs may be booked again (they keep it as history) |
externalRef, externalSource |
string | no | Shorthand for one externalRefs entry. externalSource defaults to api (a platform key: its slug). Also the job's primary id on reads |
clientCompanyId |
string | no | One of your active client companies: the customer is theirs, and they see the job like a request they filed. 404 otherwise |
siteId |
integer | no | Put this visit at an existing site (the property, for its client company). It may then carry external ids (such as acme) that a live visit at that site already holds. Your own work may go to a client's site; a client's work only to its own (422 different_client); a merged site's id puts it at the site it was merged into; 404 when there is no such site. Left out, CrewLink picks the site by the rules. The answer (201, and 202 when queued) carries siteId |
techIds |
integer[] | no | Who goes (1 to 10 people). A future time: those people if they can make it, else 409 slot_taken (or the queue with allowQueue). A past time with allowPast: recorded as given |
allowPast |
boolean | no | Default false. true: a start that has passed is written as history (see below) instead of 409 in_the_past. With allowQueue too, a past start is queued instead. Owners only (an owner's sign-in, the company API key, an act-as session, a platform key with write): 403 owner_only for dispatchers and field managers |
source |
string | no | import: a job moved from another system (the old CrewLink). Implies allowPast (and, with techIds, allowOutsideHours); the job's source is import; the customer gets no text at all: no confirmation, no queued text and (unless remind: true) no reminder; booking.created says source: "import". Owners only, like allowPast (403 owner_only for dispatchers and field managers: an import may later be double-booked by anyone who may move it) |
allowOutsideHours |
boolean | no | Imports only (422 without source: "import"); the default there when techIds is sent, false turns it off. See Old commitments on import |
remind |
boolean | no | Default true (false with source: "import"). false: no reminder text for this job. Stored on the job: the text worker also drops a queued reminder of a job with it off. An office move turns it back on |
notify |
boolean | no | Default true. false: the homeowner gets no text or email about this job, ever: no confirmation, reminder, on-the-way or ETA, running-late move, reschedule, cancellation, review link or invoice; anything already queued is dropped by the text worker. Stored on the job (homeownerTexts on reads). A move with notify: true turns it back on. A platform whose grant has homeownerMessaging: "off" always books notify: false. See Homeowner messaging |
filedAt |
string | no | With source: "import" only: when the job was first filed in the old system (ISO 8601, not in the future). Kept as the job's createdAt, so filed-date filters, aging and lead-time KPIs stay true |
geoPrecision |
string | no | How exact lat/lng are: the precision of the address-search match you picked (rooftop, address, street or locality). Stored on the job (geoPrecision on reads). street and locality (no house found) are refused with 422 approximate_address unless allowApproximate is true. Left out: not checked (your own coordinates) |
allowApproximate |
boolean | no | true: book a street- or area-level place anyway (the dispatcher ticked "Use this location anyway"); it is stored as that precision. Default false, except for imports (source: "import") and platform keys, where it defaults to true so moving work from another system keeps working. Send false to have an import checked too |
The job records who booked it (the signed-in person, or none for the company API key) and where it came from (office, or api for the company API key). People with a team or own scope see it as theirs.
curl -s -X POST "$CREWLINK/v1/office/jobs" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"serviceId": 1,
"lat": 41.955, "lng": -87.675,
"start": "2026-11-16T16:00:00Z",
"customer": { "name": "Pat Quinn", "phone": "312 555 0177" },
"address": "2210 W Addison St, Chicago",
"notes": "Side door, dog in the yard"
}'Response 201:
{
"jobId": 1042,
"start": "2026-11-16T16:00:00.000Z",
"local": "2026-11-16T10:00",
"timezone": "America/Chicago",
"techs": [{ "id": 1, "name": "Maria Gomez" }],
"manageToken": "r1Hc0vQyX9b2mN4kT7sW8zLp5uJ3eA6d",
"externalRef": null,
"externalSource": null,
"externalRefs": {}
}manageToken is the customer's manage-link token. It's shown once and is already in the confirmation text, so you only need it if you show the customer their link yourself. A platform key sees tech names as first name and last initial (Maria G.).
Sends the booking.created webhook event with source: "office" ("platform" for a platform key).
History (allowPast: true, or source: "import"). A start that has already passed is booked as given, with no lock, no availability check and no text to the customer (booking.created goes out with its techs and history: true, to webhook endpoints with historyEvents: true only; see what each path sends): the people in techIds (any of your people, active or not), else the nearest active people with the service's skills (422 when you have nobody). Each visit lasts the service's minutes. The answer is the usual 201 plus "history": true. Until it's marked done with complete (at may then go back to the job's day), it shows as overdue. A future start in the same request books normally (the lock and the recheck, with techIds if sent).
Old commitments on import (source: "import" with techIds, allowOutsideHours). A future visit the old system already confirmed with the homeowner is booked for those techs at that time even when it's outside their working hours, on a day they don't work, in the on-call window or further than the engine would drive them. Their hours are not changed. The answer adds "outsideHours": true when the visit is outside a tech's hours, and every read of the job says so too (outsideHours on the job row and on schedule visits) so the office can see it. A visit inside their hours books as usual. Still refused:
- the tech is already on another visit then (any job, any clock):
409 tech_busywithpersonId,conflictJobId(the job in the way) and amessage; withallowQueue: truethe job goes to the intake queue instead (202), as your platform's importer does; - time off on their calendar, missing skills, or someone not on the team (or not active):
409 slot_takenwith thatreason, as before.
Without source: "import" nothing changes: a time outside a tech's hours is 409 slot_taken with reason: "outside_hours". A move is checked like any booking and clears outsideHours.
Queued instead (allowQueue: true). When the time can't be booked, the job is filed as unscheduled in the intake queue with the time asked for and the reason (and its external ids and address parts), the customer is texted that the office will text them a time (not for source: "import"), and request.created goes out with queued: true. Nothing is on the schedule yet; place it with move or drop it with discard. Response 202:
{
"jobId": 1043,
"status": "unscheduled",
"queued": true,
"requested": { "start": "2026-11-16T16:00:00.000Z", "local": "2026-11-16T10:00" },
"reason": "No qualified tech can make that time: their other jobs or the drive are in the way. Pick another time."
}Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No active service with that id; no hold with that id, or the wrong holdSecret |
| 409 | in_the_past |
start has passed (without allowPast or source: "import") |
| 409 | slot_taken |
No qualified tech can make that time. reason and message say why: everyone qualified is in the on-call window, outside their hours or off, or their other jobs and the drive are in the way (see Why a time can't be booked). Never with allowQueue: true |
| 409 | duplicate_external_ref |
One of the external ids is already on another job that isn't cancelled; jobId is that job |
| 409 | tech_busy |
An import with allowOutsideHours: one of techIds (personId) is already on another visit then (conflictJobId). Never with allowQueue: true |
| 410 | hold_expired |
The hold ran out |
| 422 | invalid_request |
A field failed validation; neither serviceId + start (or local) nor holdId + holdSecret; no lat/lng; externalRef disagrees with externalRefs; filedAt or allowOutsideHours without source: "import"; filedAt in the future; history with techIds not on your team, or nobody to record it against |
| 422 | approximate_address |
geoPrecision is street or locality (the street or an area, not the house) without allowApproximate: true. Nothing is saved. Pick an exact address, or confirm the place |
#POST /v1/office/jobs/{id}/move
Move a booked job to another time and/or other techs (a drag on the board). With techIds, those people go if the job fits their day; otherwise the request fails. Without techIds, the tech(s) already on the job stay if they still fit at the new time (a two-tech job keeps both, each checked); otherwise CrewLink picks the best tech(s) then, so one who can't make it is replaced. The office's Move panel shows everyone on the job, ticked, and sends techIds only when the office changes who goes; a drag on the board sends the whole crew with the dragged row's tech swapped. If the job needs more people than you name, CrewLink adds the rest. The customer's manage link keeps working and they're texted the new time. A move turns reminder texts back on for a job booked with remind: false (an import).
Unscheduled jobs. A job an emergency or a running-late tech moved off the board with no new time (the booking.rescheduled event had needsCall: true), or one waiting in the intake queue, is booked at start the same way: same checks, same techIds rule. The customer gets a confirmation text with a new manage link (the old one stops working), and the pick-a-new-time link they were texted stops working.
Path: id (integer) job id. Booked or unscheduled jobs (bumped or queued).
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
start |
string | one of | ISO datetime with offset; not in the past. Send the current start to reassign without changing the time |
local |
string | one of | Instead of start: the wall clock on the job's own clock (its timezone), YYYY-MM-DDTHH:mm |
techIds |
array of integers | no | One person id or more; several for a crew (a two-tech job: both). All of them must fit at the new time, each checked (hours, time off, the on-call reserve, their other jobs and the drive); the first who can't is named in the 409 (personId). People in another zone's branch can't take it (not_on_team). [] unassigns a booked job: see Unassigning |
notify |
boolean | no | false: no text about this move, and none for this job from then on (stored on the job, as on POST /v1/office/jobs); true: the job's texts are on again; left out: unchanged |
allowOutsideHours |
boolean | no | Imported jobs only (source: "import"; 422 otherwise). The default with techIds; false turns it off. See Placing an import at its old commitment |
allowOverlap |
boolean | no | With techIds: book a double-booking anyway. Imported jobs (anyone who may move them), or any job when the caller is an owner (403 owner_only for dispatchers and field managers). See Owners: double-booking and past times and Keeping the old app's double-booking |
allowPast |
boolean | no | true: a start that has passed is written as history instead of 409 in_the_past. Implied for imported jobs (false turns it off); on any other job owners only (403 owner_only for dispatchers and field managers) |
curl -s -X POST "$CREWLINK/v1/office/jobs/1042/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"start":"2026-11-16T19:00:00Z","techIds":[3]}'Response 200:
{
"jobId": 1042,
"start": "2026-11-16T19:00:00.000Z",
"local": "2026-11-16T13:00",
"timezone": "America/Chicago",
"techs": [{ "id": 3, "name": "Aisha Khan" }]
}Sends booking.rescheduled with source: "office", from (the old local start; null for a bumped job) and techs. A queued request placed this way sends booking.created instead: it's booked for the first time. Every move sends its webhook, including history moves and moves with notify: false: notify only controls homeowner texts, never webhooks (what each path sends).
#Owners: double-booking and past times on any job
An owner may move any job (not only imports) onto a tech who is already busy, or to a time that has passed. An owner here is a signed-in user with role owner, the company API key, a super admin acting as the owner, or a platform key with write (it acts as the owner). Dispatchers and field managers sending allowOverlap or allowPast on a job that isn't an import get 403 owner_only ("Only an owner can double-book a tech (allowOverlap) on a job. ..."); their other moves are unchanged. Imports keep working as before for everyone who may move them.
allowOverlap: truewithtechIds: when the only thing in the way is another visit of theirs (or the drive to it), the techs go anyway, and both jobs stay on them. Still checked, as for any move: time off, skills, the team, the test split, and (unlike an import) their working hours and the on-call reserve (409 slot_takenwith thereason). The same as for an import: the answer addsdoubleBooked([{ personId, conflictJobId }], one per tech) when an overlap was actually skipped, and the moved job is flagged needs attentionDouble-booked with #<conflictJobId>(every job it overlaps).job.needs_attentiongoes out as for any flag, unless the move isnotify: false(or the job's homeowner texts are off): then only the flag and its timeline entry are written.booking.rescheduled(orbooking.created, from the queue) goes out either way. WithouttechIds:422.allowPast: true: a start that has passed is written as history, exactly as for an import in the past: booked as given (techIds, else the techs already on it;422when there are none), no availability, hours or overlap checks, visits of the service's length,overdueuntil it is completed (which may then go back to the job's own day), no homeowner text and no reminder (queued ones are dropped). The answer adds"history": true;booking.rescheduled(orbooking.createdfrom the queue) goes out with the techs andhistory: true(history emissions reach only webhook endpoints withhistoryEvents: true). WithoutallowPast, a past start is still409 in_the_pastfor everyone.
curl -s -X POST "$CREWLINK/v1/office/jobs/1043/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"local":"2026-10-27T09:00","techIds":[3],"allowOverlap":true}'{ "jobId": 1043, "start": "2026-10-27T14:00:00.000Z", "local": "2026-10-27T09:00", "timezone": "America/Chicago", "techs": [{ "id": 3, "name": "Aisha Khan" }], "doubleBooked": [{ "personId": 3, "conflictJobId": 1042 }] }The office's Move panel shows owners Book anyway (double-book) when a move is refused because the tech is busy (409 tech_busy, or 409 slot_taken with reason: "doesnt_fit"), and Record past time when it is refused as in_the_past. Dispatchers don't see either.
#Unassigning a job (techIds: [])
techIds: [] takes everyone off a booked job without changing its time. Owners, platform keys with write and dispatchers (anyone who may move the job) may do it.
- The job goes back to the intake queue as
unscheduledwithintakeReason: "Unassigned"; its visits are removed (it leaves the board, the techs' days and their calendars); job reads show the kept time inrequested({ start, local }), as for a bumped job. Place it again with a move andtechIds(the customer then gets a confirmation with a fresh manage link, unless the job's texts are off). - No homeowner text (their time hasn't changed), whether or not
notifyis sent; queued texts for the job (the reminder) are dropped.notifymay still be sent to store the job's flag. booking.rescheduledgoes out withsource: "office",status: "unscheduled",techs: [],startandlocalnull,unassigned: true,from(the old local start) andrequested({ start, local }, the time it keeps). There is no separatebooking.unassignedevent: subscribers tobooking.rescheduledwithhistoryEvents: truesee it (endpoints without it don't get unassigns).- Send no
startorlocal(422); not withallowOverlap,allowPastorallowOutsideHours(422);422for a job that isn't booked (already in the queue) or is done.
curl -s -X POST "$CREWLINK/v1/office/jobs/1042/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"techIds":[]}'{ "jobId": 1042, "status": "unscheduled", "start": "2026-11-16T19:00:00.000Z", "local": "2026-11-16T13:00", "timezone": "America/Chicago", "techs": [], "unassigned": true }The job drawer on the board has an Unassign button that does this.
#Placing an import at its old commitment
An imported job (source: "import", from POST /v1/office/jobs) that landed in the intake queue, or is already on the board, can be placed at the time the old system had confirmed, for the techs it confirmed, the way an import with techIds is booked: send techIds (and allowOutsideHours: true, or leave it out: it is implied for imports with techIds). Those techs go even outside their working hours, on a day they don't work, in the on-call window or past the drive the engine would allow; their hours are not changed. It works from the queue (queued or unscheduled: booked now, booking.created for a queued one) and from the board (booked: moved). When the techs share a clock the job goes onto it (timezone in the answer); a local time is read on that clock. The answer adds outsideHours: true when the time is outside a tech's hours, and job reads show it until an ordinary move.
Still refused: a tech already on another visit that overlaps (whatever clock it is on; the job's own visit never counts) with 409 tech_busy (personId, conflictJobId, a message with the overlap); time off, missing skills, someone not on the team (or on the other side of the test split) with 409 slot_taken. Platform keys with write may do it.
A past time for an import (allowPast, implied). For an imported job, a start that has passed is not 409 in_the_past: the move writes it as history, the way POST /v1/office/jobs writes a past import (allowPast: false turns it off; on any other job a past start stays 409 in_the_past unless an owner sends allowPast: true, and allowPast from a dispatcher or field manager is 403 owner_only). Use it for a queued or unscheduled import the old app had booked at a time that has now passed. The job is booked as given: techIds (else the techs already on it; 422 when there are none), with no availability, hours or overlap checks, and real visits of the service's length, so it reads overdue (awaiting completion) like any past booked job never marked done; finish it with complete. No homeowner text and no reminder (queued ones are dropped); booking.created (from the queue) or booking.rescheduled always goes out, with the techs and history: true (also with notify: false: notify only controls homeowner texts) (history emissions reach only webhook endpoints with historyEvents: true). The answer adds "history": true. Any other job may be moved to a past time the same way by an owner with allowPast: true (Owners). Cancelled and done imports are corrected with POST /v1/office/jobs/{id}/history instead.
curl -s -X POST "$CREWLINK/v1/office/jobs/4570/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"local":"2026-09-17T09:00","techIds":[81],"notify":false}'{ "jobId": 4570, "start": "2026-09-17T14:00:00.000Z", "local": "2026-09-17T09:00", "timezone": "America/Chicago", "techs": [{ "id": 81, "name": "Aim Torres" }], "history": true }Keeping the old app's double-booking (allowOverlap: true). The old app let a tech be on two jobs at once; an import keeps that when the move sends allowOverlap: true with techIds. The overlap with another visit of theirs is booked anyway (no 409 tech_busy) and both jobs stay on the tech. Time off, skills and the team are still checked (409 slot_taken), and outside hours is already implied. For imported jobs with techIds (422 invalid_request without techIds, or with allowOutsideHours: false); on any other job only owners may send it (Owners; 403 owner_only for dispatchers and field managers). When an overlap was actually skipped:
- the answer adds
doubleBooked:[{ "personId": 81, "conflictJobId": 4502 }](one per tech); - the moved job is flagged needs attention with the reason
Double-booked with #4502 (imported from the old app)(every job it overlaps, when there are several techs), so the office sees it on the board and in the needs-attention tab. The other job is not changed; job.needs_attentiongoes out as for any flag, unless the move isnotify: false(or the job's homeowner texts are already off): then only the flag and its timeline entry are written, and nothing is sent. The move itself behaves as any move (withnotify: false: no homeowner text;booking.rescheduledorbooking.createdalways, with the techs).
curl -s -X POST "$CREWLINK/v1/office/jobs/4568/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"local":"2026-10-27T09:00","techIds":[81],"allowOverlap":true,"notify":false}'{ "jobId": 4568, "start": "2026-10-27T14:00:00.000Z", "local": "2026-10-27T09:00", "timezone": "America/Chicago", "techs": [{ "id": 81, "name": "Aim Torres" }], "doubleBooked": [{ "personId": 81, "conflictJobId": 4502 }] }curl -s -X POST "$CREWLINK/v1/office/jobs/4568/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"start":"2026-10-12T19:00:00Z","techIds":[81]}'{ "jobId": 4568, "start": "2026-10-12T19:00:00.000Z", "local": "2026-10-12T12:00", "timezone": "America/Phoenix", "techs": [{ "id": 81, "name": "Aim T." }], "outsideHours": true }Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No booked, unscheduled or queued job with that id in your company |
| 403 | owner_only |
allowOverlap or allowPast on a job that isn't an import, from a dispatcher or field manager (Owners) |
| 409 | in_the_past |
start has passed (imported jobs: only with allowPast: false; see a past time for an import; other jobs: without allowPast: true from an owner) |
| 409 | slot_taken |
It can't be booked there. reason says why and personId names the tech: on_call_reserve, outside_hours, time_off, no_skills, not_on_team (also a test tech for a real job, or a real tech for a test client's job) or doesnt_fit (another job or the drive is in the way). See Why a time can't be booked |
| 409 | tech_busy |
An imported job placed at its old commitment overlaps another visit of that tech (personId, conflictJobId); allowOverlap: true books it anyway |
| 422 | invalid_request |
A field failed validation; allowOutsideHours on a job that isn't an import; allowOverlap without techIds (or with allowOutsideHours: false); a past time with no techs on the job and no techIds; techIds: [] with start/local, allowOverlap, allowPast or allowOutsideHours, or on a job that isn't booked or is done |
#POST /v1/office/jobs/{id}/cancel
Cancel a booked, unscheduled or queued job, including one that is already done (below). The office has no cutoff (customers and client companies do: cancelCutoffHours, see Company, settings and the API key). The customer is texted (unless notify is false, or the job's homeowner texts are off: Homeowner messaging) and the reminder is dropped; an outstanding pick-a-new-time link stops working.
Path: id (integer) job id.
Body (optional):
| Name | Type | Required | Notes |
|---|---|---|---|
reason |
string | no | Up to 200 characters. Passed on in the webhook event, not texted |
notify |
boolean | no | Default true (false for a job that is done). false: no cancellation text to the customer (for example a job that was cancelled in another system and is being moved here); queued reminders are dropped either way |
Response 200:
{ "jobId": 1042, "status": "cancelled" }Sends booking.cancelled with source: "office", reason and techs (who was going), always: notify: false only skips the homeowner's text.
#Cancelling a job that is done
Owners and dispatchers (for jobs in their scope), the company API key and a platform key with write may cancel a job that is already done. Then:
- the job reads
jobStatus: "cancelled"withcancelledAfterDone: true;doneAtis kept as history; - nobody is texted unless you send
notify: true(a done job's default isfalse); booking.cancelledcarrieswasDone: true;- KPIs and plan vs actual stop counting it as done (a day already reviewed is reviewed again without it);
- each tech's pay for it is taken back, listed in
payReversed:- from a pay week that is still open, the job line is removed and that day's mileage is redone without the stop (
how: "removed"); - from a week that is closed or paid, those lines stay as they were paid and a negative adjustment, reason
Job #<id> cancelled after completion, goes into the open week holding today (how: "adjustment",lineIdis that line). It shows in GET /v1/office/pay/lines and the pay report like any adjustment.
- from a pay week that is still open, the job line is removed and that day's mileage is redone without the stop (
{
"jobId": 1042,
"status": "cancelled",
"wasDone": true,
"payReversed": [{ "personId": 7, "amountCents": 8120, "currency": "USD", "how": "adjustment", "lineId": 5531 }]
}amountCents is what was taken back: the job pay plus what the stop added to that day's mileage. The change is recorded in the company's audit log.
Errors: 404 not_found (no booked, unscheduled or queued job with that id), 422 invalid_request. To drop a queued request with a "we can't make it" text instead of a cancellation, use discard.
#POST /v1/office/jobs/{id}/history
Correct an imported job's history: the time and techs the old system had. For a job imported with source: "import" (POST /v1/office/jobs) that is now cancelled or done, for example an old job that was filed unscheduled and then cancelled, so it has no visit, though in the old system it was booked at a real time with real techs. The job keeps its id and externalRefs.
Test people. A cancelled job keeps whoever the old system had, test people included, because it is never booked or counted. A done job follows the test split: a test client's job takes only test people, and any other job never does.
- The time and techs are set exactly as sent: no availability, working hours, drive, time-off or overlap checks. Sending it again replaces them.
- Nothing is sent: no homeowner text or email, no tech notification, and no webhook, deliberately (the only office path that sends none: it rewrites the record of a cancelled or done import, never the schedule). The job's timeline gets a
history_correctedentry (old and new time and techs), andupdatedAtmoves, soupdatedSincepicks it up: an integration mirroring techs re-reads the job from there. - The status stays as it is: cancelled stays cancelled, done stays done (
doneAtis kept). - A cancelled job stays off the schedule, the same as a job booked and then cancelled (cancelling always removes the visits): it never blocks a booking for those techs, never shows on the schedule, dispatch, the live board, a tech's day or a linked calendar, and never counts in KPIs or pay. Job reads show its corrected
start,local,endandtechs;visitson the detail stays empty. - A done job's visits are replaced (one per tech and day of the service), so it keeps counting as a done job does. What was recorded when it was completed (plan vs actual, tech pay lines) is left as it was.
Path: id (integer) job id.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
start |
string | one of start / local |
The instant (ISO 8601 with offset) |
local |
string | one of start / local |
YYYY-MM-DDTHH:MM on the job's clock |
end |
string | no | When the visit ended (ISO 8601), after start and within 24 hours of it. Default: start plus the service's booked length (what a booking blocks) |
techIds |
integer[] | yes | 1 to 20 people of your company (inactive people are fine) |
curl -s -X POST "$CREWLINK/v1/office/jobs/4571/history" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"start":"2026-08-03T14:00:00Z","techIds":[81]}'Response 200:
{ "jobId": 4571, "status": "cancelled", "start": "2026-08-03T14:00:00.000Z", "local": "2026-08-03T09:00", "end": "2026-08-03T15:50:00.000Z", "endLocal": "2026-08-03T10:50", "timezone": "America/Chicago", "techs": [{ "id": 81, "name": "Alex Rivera" }] }Platform keys need write and see techs as "First L.".
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No job with that id in your company |
| 422 | not_history |
Not an import (source isn't import), or not cancelled or done (booked, queued, unscheduled: use move) |
| 422 | invalid_request |
No start/local, end not after start or more than 24 hours later, a tech id that isn't one of your people, or a tech on the other side of the test split |
There is no DELETE for jobs: cancel them, and correct an import's history with this.
#GET /v1/office/jobs/{id}/photos
Photos the tech took on a job. Each url is a view link that works for 15 minutes; fetch the list again for fresh links.
Path: id (integer) job id (any status).
Response 200:
{
"photos": [
{
"id": "6c1e9a52-3b7f-4d0e-a8c4-2f9d1b7e5a10",
"url": "https://<signed view link, valid 15 minutes>",
"caption": "Cracked heat exchanger",
"at": "2026-10-15T15:42:07.000Z"
}
]
}Only finished uploads are listed. Errors: 404 not_found.
#GET /v1/office/jobs/{id}/texts
The conversation with the customer about a job: every text CrewLink sent or will send for it (confirmation, reminder, move, cancellation, on the way, review link, invoice, deposit, and the rest), newest first, up to 200. The customer's number is masked to its last 4 digits.
Path: id (integer) job id (any status).
curl -s "$BASE/v1/office/jobs/4182/texts" -H "Authorization: Bearer $KEY"Response 200:
{
"jobId": 4182,
"texts": [
{
"id": 90211,
"kind": "review",
"channel": "sms",
"to": "***-***-0142",
"body": "Northline Heating & Air: thanks for having us. How did we do? It takes 10 seconds: https://go.crewlink.ai/r/4182?t=...",
"status": "sent",
"sendAfter": "2026-10-14T16:52:00.000Z",
"sentAt": "2026-10-14T16:52:00.000Z"
},
{
"id": 90187,
"kind": "on_my_way",
"channel": "sms",
"to": "***-***-0142",
"body": "Northline Heating & Air: Maria is on the way, about 18 min out.",
"status": "sent",
"sendAfter": "2026-10-14T15:02:00.000Z",
"sentAt": "2026-10-14T15:02:00.000Z"
}
]
}kind is one of confirmed, reminder, rescheduled, cancelled, on_my_way, review, invoice, deposit, recurring_booked, bumped, request_received, request_declined, bumped_choices, request_queued. status: queued (waiting for sendAfter, e.g. a reminder), sent, failed (after three tries) or cancelled (dropped before it went out, because the job moved or was cancelled); sentAt is null unless it was sent. Errors: 404 not_found (no such job, or another company's).
#Intake and job search
Every job in one place. The intake queue holds every job that has no time yet, oldest first:
- requests nobody could take at the time asked, when whoever sent them asked to queue instead of being refused (
allowQueue: trueon POST /v1/office/jobs, on a booking-page hold in booking-page.md, or on a CrewConnect send in crewconnect.md), - out-of-area requests from the booking page (see Out-of-area requests and trips),
- booked jobs an emergency or a running-late tech moved off the board with no new time.
The office places each one with move (or accept for an out-of-area request), leaves it in the queue, or drops it with discard. GET /v1/office/jobs (under Jobs for integrations: one list for the office and integrations) searches every job, past and future.
#Job statuses
Every job read (the list, the detail, intake risks) has the stored status and stage, and what people see: jobStatus and two flags. Visits on the schedule carry jobStatus, overdue and needsAttention too.
jobStatus |
Meaning |
|---|---|
unscheduled |
No time yet: in the intake queue (status queued, waiting or unscheduled) |
scheduled |
On the schedule, not started |
en_route |
The tech tapped On my way |
on_site |
The tech arrived |
done |
The tech (or the office) marked it done |
cancelled |
Cancelled, or a request that was declined or discarded (status cancelled or declined) |
| Flag | Meaning |
|---|---|
overdue |
Its last visit has ended and it isn't done. Usually the tech forgot to tap done |
needsAttention |
The office flag, { reason, at } or null. Set with POST /v1/office/jobs/{id}/attention, when a tech asks for an office review at check-in (field-app.md), when a text to the customer fails after three tries (the reason says which), and when a booked job is not closed out (Not closed out: still on site since 14:05); each sends job.needs_attention (not closed out: also when the job's notify is false) |
Holds in progress on the booking page are never listed.
#The job row
Every office read of a job returns the same object: the job summary below, which GET /v1/office/jobs/{id} extends. The fields for intake:
| Field | Notes |
|---|---|
jobStatus, overdue, overdueDays |
What people see, and whole days since the last visit ended when overdue |
city, state |
Read from the free-text address, best effort (state is a two-letter US state) |
customer.phone |
The customer's phone |
ageDays |
Whole days since it was filed (createdAt) |
requested |
Queued and moved-off-the-board jobs: the time asked for (start, local), or the time it had |
requestedDays |
Out-of-area requests: the days the customer picked |
intakeReason |
Queued jobs: why it couldn't be booked when it was asked for |
source |
office (a signed-in person), api (the company key), platform, booking_page, client, out_of_area_request, crewconnect, recurring, emergency or import. A time held with POST /v1/office/holds and then booked reads whoever held it (platform, office or api) |
sentBy |
CrewConnect: the company that sent it |
client, filedBy |
The client company the job is for ({ id, name }), and who filed it ("Filed by Ann Lee · Globex Solar") |
outsideHours |
true: booked outside its tech's working hours or on a day they don't work, honouring a time the old system had confirmed (import). Their hours were not changed; a move clears it |
siteId, siteVisit |
The site the visit belongs to (null until linked: a hold), and its place there: { number, of, revisit }, "Visit 3 at this site". Cancelled visits aren't counted, so number is null on a cancelled one |
geoPrecision |
How exact lat/lng are, as reported when it was booked: rooftop, address, street or locality. street and locality are approximate places someone confirmed ("Use this location anyway"): the tech may need directions. null when not reported (your own coordinates, older jobs) |
techs, end on a cancelled job |
A cancelled job has no visits, so techs is [] and end is null, except for an import whose history was corrected (POST /v1/office/jobs/{id}/history): its old techs and end |
People with a team or own scope get only their own jobs in the list, the detail, the intake counts and the CSV.
#GET /v1/office/intake
How many jobs are in each tab, and the dispatch risks: jobs still unscheduled 3 or more days after they were filed (oldest first), then visits that ended without being marked done. Rows for a tab come from GET /v1/office/jobs with tab.
Query (all optional, they narrow the counts): q, techId, serviceId, source, clientCompanyId or client, filedFrom, filedTo, as for GET /v1/office/jobs.
Response 200:
{
"counts": { "all": 412, "unscheduled": 3, "scheduled": 57, "overdue": 2, "needs_attention": 1, "done": 331, "cancelled": 21 },
"risks": [
{ "kind": "aging", "job": { "id": 1031, "status": "unscheduled", "ageDays": 5, "...": "a job row" } },
{ "kind": "overdue", "job": { "id": 1012, "status": "scheduled", "overdue": true, "overdueDays": 1, "...": "a job row" } }
],
"agingDays": 3
}scheduled counts upcoming work only: overdue jobs count under overdue. A job can be in needs_attention and one other tab.
#GET /v1/office/jobs.csv
The list as a spreadsheet: the rows GET /v1/office/jobs would return for these filters and sort, up to 5,000, as CSV (RFC 4180, UTF-8 with a byte order mark). Columns: job_id, status, job_status, overdue, needs_attention, attention_reason, customer, phone, address, city, state, service, filed (your company's zone), scheduled, requested (the job's own zone, named in timezone), timezone, techs, source, sent_by, external_ref. Same query as the list, without limit and cursor.
#GET /v1/office/intake/{id}/options
Who could take an unscheduled job on a day: every qualified tech whose home is within radius of the job, closest first, with their open starts that day (15-minute steps) and the drive to each from where they'd set off (the job before, else home). Use it to place a job from the queue, then book the start with move and techIds (or accept for an out-of-area request).
Query: date (YYYY-MM-DD, local, required); radius (in units, 1 to 2500; default 100 mi or 160 km).
Response 200:
{
"jobId": 1043,
"date": "2026-10-14",
"units": "mi",
"radius": 100,
"outsideArea": false,
"techs": [
{
"id": 1, "name": "Maria Gomez", "distance": 2.2, "driveMinutes": 6,
"slots": [
{ "start": "2026-10-14T13:00:00.000Z", "local": "2026-10-14T08:00", "driveMinutes": 6, "from": "home" },
{ "start": "2026-10-14T16:30:00.000Z", "local": "2026-10-14T11:30", "driveMinutes": 9, "from": "previous_job" }
]
}
],
"crews": []
}| Field | Notes |
|---|---|
units |
mi in the US and Canada, else km |
distance |
Straight line from the tech's home, in units |
driveMinutes |
A rough road estimate |
outsideArea |
The address is outside every service area. Out-of-area requests may go to anyone qualified; other jobs only to techs whose area reaches them |
crews |
For a service that needs more than one person: open starts with who would go (then techs[].slots is empty) |
Errors: 404 not_found (no unscheduled job with that id), 422 invalid_request.
#POST /v1/office/intake/{id}/discard
Drop a job from the intake queue. A queued or out-of-area request is declined and the customer is texted that you can't make it (with message added, e.g. a referral). A bumped job (it had a time before) is cancelled with the usual cancellation text and booking.cancelled event.
Body (optional): message (up to 300 characters), notify (default true; false sends no text).
Response 200: { "jobId": 1043, "status": "cancelled" }. Errors: 404 not_found (no unscheduled job with that id).
#Jobs for integrations
Reading and finishing jobs from another system: a CRM, or a platform (platforms.md walks through the whole flow). Every company key works; a platform key sees techs as first name and last initial ({ "id": 3, "displayName": "Maria G." }) and needs the scopes listed in platforms.md.
#GET /v1/office/jobs
Every job of the company, past and future: one list for syncing and for the office's search (the Jobs and Intake pages). By default ordered by when it last changed (updatedAt, then id), oldest first. Bare holds are left out unless you ask for status=held.
| Query | Notes |
|---|---|
status |
The stored status: one or more of booked, held, waiting, queued, unscheduled, declined, cancelled, passed, comma separated |
jobStatus |
What people see: one or more of unscheduled, scheduled, en_route, on_site, done, cancelled (Job statuses) |
tab |
An intake tab: all, unscheduled, scheduled (upcoming, not overdue), overdue, needs_attention, done, cancelled |
q |
Customer name, phone (4 or more digits, any formatting), address or city, or an external id; #1043 for one job |
from, to |
Local dates (YYYY-MM-DD) of the start, inclusive, past or future. Leaves out jobs with no time (waiting, queued, unscheduled, declined) |
filedFrom, filedTo |
Local dates the job was filed (created), inclusive |
techId, serviceId |
Jobs with this person on them; of this service |
source |
Where it came from, comma separated (see the job row) |
clientCompanyId, client |
Work for this client company, by id or CrewLink ID |
sort |
updated_asc (default), filed_desc, filed_asc, start_desc or start_asc. Send the same sort with cursor. With externalRef and no sort: jobs that aren't cancelled first, then the newest filed |
updatedSince |
ISO instant: jobs changed at or after it |
siteId |
The visits of one site |
externalSource, externalRef |
Jobs with that external id (any of a job's ids, not only the primary one); either alone works too. Cancelled jobs keep their ids, so an id that was cancelled and booked again lists every job that had it; by default the live one comes first (jobs[0] is the job the id points to now; all cancelled: the newest) |
cursor |
nextCursor from the previous page |
limit |
1 to 200, default 50 |
Response 200: { "jobs": [ <job summary>, ... ], "nextCursor": "<string or null>", "total": 38 }. A summary has id, status, stage, jobStatus, overdue, overdueDays, service (id, name, category), start/local/end/endLocal (null for jobs with no time) and timezone, address, city, state, lat, lng, customer (name, phone), tech (the first assigned) and techs, externalRef, externalSource, externalRefs, needsAttention ({ reason, at } or null), doneAt, createdAt, updatedAt, and the intake fields in the job row. The full shape, with an example, is in platforms.md. total counts every match. Errors: 422 invalid_request (an unknown status, a bad date or range, a cursor this endpoint didn't give out). GET /v1/office/jobs.csv returns the same search as CSV.
#GET /v1/office/jobs/{id}
One job in full: the summary plus customer (name, phone, email), addressParts, notes (from booking), visits (each tech-day with times), timeline (created, then en_route, arrived, checkin, done, note, attention, attention_cleared, history_corrected, each with at, by tech/office/system, the tech and any text), attachments (with 15-minute download links), textsCount and arrivedAt. Like every job read (the list and intake too) it has homeownerTexts: false when the job sends its homeowner nothing (Homeowner messaging). Errors: 404 not_found.
#POST /v1/office/jobs/{id}/complete
Mark a booked job done for work finished outside the field app.
| Body | Type | Notes |
|---|---|---|
at |
string | When it was finished (ISO 8601). Default now; up to 30 days back, not in the future (422 invalid_at). A job that started earlier than that (history: an import, or a past time written with allowPast on a booking or an owner's move) may go back to the job's own day |
notes |
string | Up to 4000 characters, on the timeline's done entry |
silent |
boolean | Default false. true: no review-link text and no CrewConnect payout; job.completed goes out with silent: true to webhook endpoints with historyEvents: true only |
pay |
boolean | Default true: each tech's pay for the job goes to the pay period of at. false: no pay line (history whose pay was settled outside CrewLink, such as an import) |
Always: the stage becomes done (doneAt = at), each visit is recorded as done in plan vs actual (on the day of at, or the person's last visit before it when the work was marked done later; KPIs count the job on the day it started) and queued reminder texts are dropped. Unless silent: the customer is texted the review link (30 minutes after at, or now), a CrewConnect payout is scheduled when a partner did the work, and job.completed goes out with source: "api" (a company or platform key) or "office" (a signed-in user).
Response 200: { "jobId": 1042, "stage": "done", "doneAt": "...", "silent": false, "minutesOnSite": 94 } (minutesOnSite is null without an arrival tap). Errors: 404 not_found, 409 already (it's done), 409 not_booked (not a booked job), 422 invalid_at.
#POST /v1/office/jobs/{id}/notes
{ "text": "Gate code 4411" } (1 to 4000 characters). Adds an office note to the timeline; the tech sees it in the job's history in the field app. Response 201: { "id": 88, "at": "..." }. Errors: 404 not_found.
#POST /v1/office/jobs/{id}/attention and DELETE /v1/office/jobs/{id}/attention
POST { "reason": "Roof access unclear" } (1 to 500 characters) flags the job: needsAttention shows on the list and the detail, the timeline gets an attention entry and job.needs_attention goes out. Flagging again replaces the reason. DELETE clears it (timeline: attention_cleared; no webhook). The same flag (timeline entry and webhook) is raised by a tech's office_review check-in (source: "tech"), by a customer text that fails after three tries (source: "system", only when the job isn't flagged already) and by a job not closed out (source: "system", by: "closeout"). Responses 200: { "jobId": 1042, "needsAttention": { "reason": "...", "at": "..." } } and { "jobId": 1042, "needsAttention": null }. Errors: 404 not_found.
#Not closed out
Every 15 minutes CrewLink looks for booked jobs that should be finished but aren't marked done, and flags them needs attention (shown on Dispatch's risks, the Needs attention tab and the job), so the office can chase the tech or close the job:
- When: 3 hours after the job's last visit's scheduled end, or at 21:00 on that day when that comes sooner (and the visit has ended), in the job's own time zone. A visit ending at 11:15 is flagged at 14:15; one ending at 19:30 at 21:00; one ending at 22:00 at 01:00.
- Which: status booked and not done, whatever its stage (not started, on the way, on site). Not imports (
source: "import") or anything written as history (booked at or after its own start:allowPaston a new job or from the queue), not held, queued or cancelled jobs, and only visits that ended in the last two days. Test jobs are included. - Reason:
Not closed out: still <stage> since <time>:not started since 10:00(the scheduled start),on the way since 09:40(the on-the-way tap),on site since 13:05(arrival); the date is added when it isn't today. - Once per job. A job already flagged for something else keeps that flag (it's looked at again once that flag is cleared). After the office clears a not-closed-out flag (
DELETE), it is never flagged again for this. - What goes out: the flag, an
attentiontimeline entry andjob.needs_attentionwithsource: "system",by: "closeout", also for a job whosenotifyis false (notifyonly controls homeowner texts and emails, never webhooks). No text or email to the homeowner and no push to the tech.
#GET /v1/office/catalog
The company and its active services, the same answer as the public GET /v1/public/{slug} (each service with category), for a caller with a key.
#POST /v1/office/availability
Open start times at an address, the same body and answer as the public POST /v1/public/{slug}/availability, without the per-IP limits, plus:
| Body | Notes |
|---|---|
excludeJobId |
A job of yours whose visits don't count, so a reschedule sees the times it could move to (404 for someone else's job) |
leadMinutes |
Nothing sooner than this many minutes from now; 0 to 1440, default 60 |
earliestDay |
next_business_day: nothing before the next Monday-to-Friday day after today, in the zone of the address (Friday, Saturday and Sunday give Monday; public holidays aren't known, so block them as time off). With leadMinutes, the later of the two wins |
stepMinutes |
The grid of local minutes start times lie on: 1, 5, 15 (default, as before), 30 or 60. 60 gives whole hours only, 1 any minute. Every start is still travel- and job-feasible; the efficiency filter (starts with more than 20 minutes' extra driving than the day's best are hidden, counted in predicted drive minutes, not the padded minutes a plan allows) compares against the best start on the same grid. Anything else is 422 invalid_request |
There is no cap on days for a fine grid, but the answer grows with it: on a busy 8-tech company a 28-day search at stepMinutes: 1 returned about 6,000 starts (400 KB) in under a second, against about 400 at 15. Ask for the days you will show.
Any start it returns, on any grid, holds and books as given: POST /v1/office/holds with its start, or POST /v1/office/jobs with its local (and the same techIds from first-available, if you want those people).
A platform key needs only the read scope for it.
#POST /v1/office/holds
{ serviceId, lat, lng, start }, plus optional leadMinutes (default 0), earliestDay (next_business_day, as on availability) and notify. Rechecks the time under a lock, assigns the best tech(s) and holds it for 5 minutes. Unlike the booking page's holds there is no per-IP cap and no one-hour lead unless you send one; the time just must not have passed. notify: false books the job with no homeowner texts or emails at all, kept when the hold is booked (the default when your platform grant has homeownerMessaging: "off"). Book it with POST /v1/office/jobs { holdId, holdSecret, customer, ... }. Response 201: { "holdId": 1043, "holdSecret": "...", "start": "...", "local": "...", "expiresAt": "...", "notify": true }. Errors: 404 (service), 409 in_the_past, 409 too_soon (inside leadMinutes, or before the next business day with earliestDay), 409 slot_taken.
#Files on a job
Proposals, photos and documents (PDF, JPEG, PNG, HEIC, WebP, plain text, CSV, .docx, .xlsx), up to 20 MB each. Techs on the job see them in the field app (GET /v1/tech/jobs/{id}/attachments). Each upload sends attachment.uploaded.
| Method | Path | What |
|---|---|---|
| POST | /v1/office/jobs/{id}/attachments |
{ kind, name, contentType, bytes } (kind: proposal, photo or document) → 201 { attachment, uploadUrl, expiresAt }. PUT the bytes to uploadUrl within 15 minutes with the same Content-Type |
| POST | /v1/office/jobs/{id}/attachments/{attachmentId}/uploaded |
Confirm the PUT. CrewLink checks storage: 409 not_uploaded when nothing is there yet; 422 upload_mismatch when the file is bigger than bytes or 20 MB, or isn't the declared type (the file and the pending attachment are removed: add it again); 502 storage_unavailable when storage didn't answer. Then the file is listed with its real bytes and attachment.uploaded goes out (once) |
| POST | /v1/office/jobs/{id}/attachments/upload |
The file through the API in one call: multipart/form-data (a file part; kind, optionally name, as fields or query) or the raw bytes with the file's own Content-Type and ?kind=&name=. 201: the attachment. 413 too_large, 415 unsupported_type, 502 storage_unavailable |
| GET | /v1/office/jobs/{id}/attachments |
Uploaded files, each with url, a signed download link good for 15 minutes |
| DELETE | /v1/office/jobs/{id}/attachments/{attachmentId} |
Remove one; the file is deleted from storage too. A platform key removes only files that platform added (403 otherwise) |
The attachment object: id, jobId, kind, name, contentType, bytes, status (pending until confirmed, uploaded), uploadedBy ({ kind: "platform" | "user" | "company", id }), createdAt, uploadedAt, url (null until uploaded; the download carries the file's name).
#Sites
A job in CrewLink's API (jobId, bookingId) is one visit: one appointment on the schedule. A site is one property (address) for one client company (or your own work), holding every visit ever made there, forever: the survey, revisits, a visit cancelled and booked again, next year's cleaning. Every visit belongs to one site (siteId on job reads, on the 201/202 of POST /v1/office/jobs, and on every webhook about a job). The office's Jobs page shows one card per site; Dispatch and the field app stay per visit and say "Visit 3 at this site". Nothing about visits changed: their ids, externalRefs and booking.* webhooks mean what they did. Design: data-model.md.
#How visits are grouped
When a visit is created (or, for one booked before sites existed, at its next event or the backfill), it goes to a site by the first rule that fits:
siteIdsent onPOST /v1/office/jobs.- The same external id as another visit or a site, on a booking that isn't an import, such as your platform's
acmeid (one platform job = one site). Per-visit ids never group: a source ending inVisit(acmeVisit) and the old CrewLink's request ids (crewlink-v1); nor do an import's ids (source: "import"). - The same address and client company. The address is compared by its normalized street line (
12 N. Elm Street, Apt 2=12 north elm st #2) within 500 m; without an address, within 30 m. The customer doesn't matter (the homeowner can change), and neither does how long ago. - Your own work (no client company) goes to the client's site at that address when exactly one client company has a site there; otherwise to your own site.
- Otherwise a new site.
Two client companies never share a site. A visit of a client placed at an address where your own site already is folds your own site into the client's (site.merged). An external id may be on more than one live visit only when they share a site: a revisit service carrying it joins that site, and any other visit needs siteId (else 409 duplicate_external_ref as before). Per-visit ids are never shared.
#Status
status |
When |
|---|---|
active |
A visit is scheduled or in progress, has no time yet (queued, out-of-area, moved off the board), or needs attention |
idle |
Nothing to do now: every visit is done or cancelled |
merged |
Folded into mergedIntoId (merge); kept, never deleted |
lastVisit and nextVisit say when someone was last there and is next going.
#The site object
| Field | Notes |
|---|---|
id |
The site id (siteId elsewhere) |
status |
Above |
address, city, state, lat, lng |
Of its first visit |
customer |
{ name, phone } of its most recent visit |
client |
{ id, name } of the client company, or null for your own work |
externalRefs |
The site's ids by source: its most recent visit's (per-visit ids left out), plus any you set |
mergedIntoId |
Set on a merged site |
firstFiledAt |
When its first visit was filed |
updatedAt |
Last activity: a visit added, moved, worked or cancelled, or the site changed |
counts |
{ visits, active, done, cancelled } |
lastVisit |
{ jobId, at, service }: the most recent visit done, or null |
nextVisit |
{ jobId, start, service }: the next scheduled visit not yet done, or null |
visits |
Its visits you may see, oldest filed first, each a job row (siteVisit numbers them) |
People with a team or own scope see the sites that hold one of their visits, and only those visits, and a merged site only when the site it was merged into holds one of their visits (a merged site has no visits of its own); merge, split and PATCH are for everyone else. Platform keys see techs as "First L." (and need write for PATCH, merge and split).
#GET /v1/office/sites
One row per site, each with its visits. Takes every filter of GET /v1/office/jobs except sort (q, jobStatus, tab, status, from/to, filedFrom/filedTo, techId, serviceId, source, clientCompanyId, client): a site is listed when any of its visits matches, and shows all of them. q finds a site by customer name, phone, address, city or any visit's external id; #123 by a visit id. Plus:
| Query | Notes |
|---|---|
externalSource, externalRef |
Sites with that id of their own, or with a visit that has it |
siteStatus |
active, idle, merged, comma separated. Merged sites are left out unless asked for |
updatedSince |
Sites with activity at or after this instant |
sort |
updated_asc (default, for syncing), updated_desc (most recent activity first: the Jobs page), filed_desc, filed_asc |
cursor, limit |
As on the job list (1 to 200, default 50) |
Response 200: { "sites": [ <site>, ... ], "nextCursor": "<string or null>", "total": 12 }.
{
"id": 482,
"status": "active",
"address": "1420 W Elm St, Phoenix, AZ 85013",
"city": "Phoenix",
"state": "AZ",
"lat": 33.51,
"lng": -112.09,
"customer": { "name": "Pat Quinn", "phone": "+16025550177" },
"client": { "id": "57f6...", "name": "Globex Solar" },
"externalRefs": { "acme": "job_7731" },
"mergedIntoId": null,
"firstFiledAt": "2026-10-01T15:02:11.000Z",
"updatedAt": "2026-10-12T15:30:00.000Z",
"counts": { "visits": 3, "active": 1, "done": 1, "cancelled": 1 },
"lastVisit": { "jobId": 9120, "at": "2026-10-07T18:40:00.000Z", "service": "Residential Solar Site Survey" },
"nextVisit": { "jobId": 9188, "start": "2026-10-14T16:00:00.000Z", "service": "Residential Survey Revisit" },
"visits": [ { "id": 9104, "status": "cancelled", "siteVisit": { "number": null, "of": 2, "revisit": false } }, { "id": 9120, "...": "..." }, { "id": 9188, "...": "..." } ]
}#GET /v1/office/sites/{id}
The site plus timeline (oldest first) and activity ([{ jobId, photos, texts, messages }]; messages is 0 for platform keys). Each timeline entry is { kind, at, jobId, visit, service, by, tech, text }: kind is requested or booked (each visit filed), en_route, arrived, checkin, done, note, attention, attention_cleared, history_corrected, cancelled, photo, text (a text to the customer), or the site's own site_created, linked (a visit joined, and by which rule), merged, merged_into, split, split_from, refs; visit is the visit's place at the site; by is tech, office, customer or system. A merged site answers with status: "merged", mergedIntoId and no visits. 404 when it isn't yours (or, for a team or own scope, holds none of your visits; a merged site: the site it was merged into holds none).
#PATCH /v1/office/sites/{id}
{ "externalRefs": { "hubspot": "deal-7", "crm": null } }: sets each source's id for the site; null removes it; other sources stay. Audited (refs on the timeline); sends site.updated. 200 with the site; 409 already_merged on a merged site.
#POST /v1/office/sites/{id}/merge
{ "fromSiteId": 481 }: every visit of site 481 moves to this one (the survivor), their ids join this site's (this site's win when both have the same source), and 481 is kept with status: "merged" and mergedIntoId (so GET /v1/office/sites/481 still answers, and siteId: 481 on a new visit lands at the survivor). The same client company, or your own work into a client's site (it keeps the client). Audited on both timelines; sends site.updated and site.merged. 200 with the survivor plus mergedSiteId and movedJobIds. Errors: 422 different_client (two client companies, or a client's site into your own work), 422 invalid_request (into itself), 409 already_merged, 404.
#POST /v1/office/sites/{id}/split
{ "jobId": 9188 } takes that visit out of this site to a new site of its own; with "toSiteId": 475 it moves to that site instead (the same client company, or a client's site for your own work; 422 different_client otherwise). 409 only_visit when it is the site's only visit and there's no toSiteId. When the last visit moves out, this site is kept as merged into toSiteId. Audited on both timelines; sends site.updated for each (site.created for a new one). The backfill never merges a split site back. 200: { "from": <this site, or null when emptied>, "to": <the site it went to> }.
#What counts per site, and what per visit
The Jobs page (Sites view) and these endpoints count sites, and the Clients list's sites / activeSites. Everything about work done stays per visit, because that is what a tech drives to and works: the schedule, dispatch, the live board, the field app, billing (each booked visit counts on the plan), invoices, tech pay, KPIs and reports (on-time, jobs per day, revenue per job), plan vs actual and Wins. The Clients list's jobs and openRequests stay visit counts.
#Emergencies
#POST /v1/office/emergencies
Book a same-day emergency (no heat, a burst pipe). CrewLink takes the earliest open time today starting 30 minutes from now, including the on-call reserve that regular customers can't book (set with PATCH /v1/office/settings).
If the day is full, CrewLink moves one flexible job to make room. It tries the best few options in full and keeps the one that gets the emergency in soonest while moving the other customer least, preferring one-person jobs. That customer is texted their new time (within 14 days). If nothing fits within 14 days, the job comes off the schedule and the customer is texted a link to pick one of a few new times; the response shows to: null for it, and the office may want to call. Jobs booked with flexible: false and emergencies themselves are never moved.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes | One of your active services |
lat |
number | yes | -90 to 90 |
lng |
number | yes | -180 to 180 |
customer.name |
string | yes | 1 to 200 characters |
customer.phone |
string | yes | 7 to 40 characters |
customer.email |
string | no | A valid email |
address |
string | no | Up to 300 characters |
notes |
string | no | Up to 2000 characters |
There's no start: CrewLink chooses it.
curl -s -X POST "$CREWLINK/v1/office/emergencies" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"serviceId": 2,
"lat": 41.955, "lng": -87.675,
"customer": { "name": "Nora Lind", "phone": "312 555 0111" },
"address": "4410 N Damen Ave, Chicago",
"notes": "No heat, elderly resident"
}'Response 201:
{
"jobId": 1311,
"start": "2026-10-12T16:00:00.000Z",
"local": "2026-10-12T11:00",
"techs": [{ "id": 1, "name": "Maria Gomez" }],
"manageToken": "kX9vQ2mR7tY4wN1pL8sD3fH6jB0cA5zE",
"moved": [
{ "jobId": 1290, "from": "2026-10-12T11:30", "to": "2026-10-13T08:00", "customer": "Jamie Cruz" }
]
}| Field | Notes |
|---|---|
manageToken |
The customer's manage token (also in their confirmation text), shown once, as with POST /v1/office/jobs |
moved[] |
Jobs moved to make room (at most one today). from and to are local wall clock. to: null means it couldn't be rebooked within 14 days; the customer got a pick-a-new-time link, and the office can book it with POST /v1/office/jobs/{id}/move |
The emergency itself is pinned (never moved by a later emergency). The customer gets a confirmation text with their manage link.
Webhook events: booking.created with source: "emergency" for the new job, and booking.rescheduled with source: "emergency" for each moved job (with from, and start: null, local: null, needsCall: true when it couldn't be rebooked).
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No active service with that id |
| 409 | no_one_today |
No qualified tech can get there today, even by moving a flexible job. Offer tomorrow's first time, or send it to a partner on CrewConnect (crewconnect.md). reason says why: outside_hours (no one who can do it is working long enough today; the message gives the last shift's end), no_skills (not enough people with the skills), or doesnt_fit (the drive and today's jobs are in the way) |
| 422 | invalid_request |
A field failed validation |
#Calendar view and calendar connections
#GET /v1/office/calendar
Day-by-day totals for month and week views: jobs, booked versus working minutes, revenue at list price, the service mix and when jobs start.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
from |
string | yes | YYYY-MM-DD |
to |
string | yes | YYYY-MM-DD, inclusive, not before from. At most 62 days in all |
curl -s "$CREWLINK/v1/office/calendar?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"Response 200:
{
"days": [
{
"date": "2026-11-16",
"jobs": 14,
"bookedMinutes": 1185,
"workMinutes": 4320,
"revenueCents": 152400,
"mix": { "Seasonal tune-up": 9, "Repair visit": 4, "New system install": 1 },
"hours": [3, 2, 1, 1, 0, 2, 2, 1, 1, 1, 0]
}
]
}| Field | Notes |
|---|---|
jobs |
Booked jobs with a visit that day |
bookedMinutes |
Sum of every person's booked visit minutes that day (a two-person job counts twice) |
workMinutes |
Sum of active people's working hours for that weekday (time off isn't subtracted) |
revenueCents |
List price of those jobs |
mix |
Jobs per service name |
hours |
11 buckets of job start times: 7:00, 8:00, ... 17:00. Starts before 7:00 count in the first bucket, after 17:00 in the last |
Errors: 422 invalid_request (bad dates, to before from, or more than 62 days).
#GET /v1/office/calendars
Which techs have linked a Google or Outlook calendar, and whether it's syncing. Techs link their own calendars from the field app (field-app.md). Busy time in a linked calendar blocks bookings for the next 21 days, and the tech's CrewLink jobs appear in that calendar.
Response 200:
{
"calendars": [
{
"personId": 1,
"provider": "google",
"account": "maria@northline.example",
"status": "ok",
"lastSyncAt": "2026-10-12T15:25:00.000Z",
"lastError": null
}
]
}| Field | Notes |
|---|---|
provider |
google or outlook |
account |
The calendar account's address, when the provider shares it |
status |
ok, or error when the last sync failed. Sync is retried every few minutes |
lastError |
A plain-language message when status is error. If it persists, the tech should unlink and link again |
#Credentials
Licenses, insurance, certifications and background checks per person, with expiry dates. CrewConnect partners can require some kinds before your people are offered their work (crewconnect.md).
The credential object:
| Field | Type | Notes |
|---|---|---|
id |
integer | |
personId |
integer | |
person |
string | The person's name |
kind |
string | license, insurance, certification or background_check |
name |
string | e.g. "Illinois HVAC Contractor License" |
number |
string or null | |
issuer |
string or null | |
expiresOn |
string | YYYY-MM-DD |
daysLeft |
integer | Days from today (company time) until expiresOn. Negative once expired |
documentId |
string or null | The uploaded copy (documents) |
#POST /v1/office/team/{id}/credentials
Add a credential for someone.
Path: id (integer) person id.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
kind |
string | yes | license, insurance, certification or background_check |
name |
string | yes | 1 to 200 characters |
number |
string | no | Up to 100 characters |
issuer |
string | no | Up to 200 characters |
expiresOn |
string | yes | YYYY-MM-DD |
documentId |
string | no | One of this person's documents; 404 otherwise |
curl -s -X POST "$CREWLINK/v1/office/team/1/credentials" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"kind":"license","name":"EPA Section 608 Universal","number":"608-44120","issuer":"US EPA","expiresOn":"2027-03-31"}'Response 201:
{
"id": 33,
"personId": 1,
"person": "Maria Gomez",
"kind": "license",
"name": "EPA Section 608 Universal",
"number": "608-44120",
"issuer": "US EPA",
"expiresOn": "2027-03-31",
"daysLeft": 170
}Errors: 404 not_found (no one with that id on your team), 422 invalid_request.
#GET /v1/office/credentials
All credentials, soonest to expire first.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
expiringWithin |
integer | no | 0 to 3650. Only those expiring within this many days, or already expired |
curl -s "$CREWLINK/v1/office/credentials?expiringWithin=30" -H "Authorization: Bearer $KEY"Response 200:
{
"credentials": [
{ "id": 12, "personId": 2, "person": "James Okafor", "kind": "insurance", "name": "General liability", "number": "GL-90311", "issuer": "Acme Mutual", "expiresOn": "2026-10-01", "daysLeft": -11 },
{ "id": 19, "personId": 5, "person": "Priya Shah", "kind": "license", "name": "City of Chicago Gas Fitter", "number": null, "issuer": "City of Chicago", "expiresOn": "2026-10-30", "daysLeft": 18 }
]
}Errors: 422 invalid_request (bad expiringWithin).
#DELETE /v1/office/credentials/{id}
Path: id (integer) credential id.
Response 200:
{ "deleted": true }Errors: 404 not_found.
#Users and tokens
Users are the people who sign in: owners, field managers and dispatchers run the office (this page), techs use the field app. A tech user is linked to their person on the schedule (personId) and can only see their own day. A field manager runs a team of dispatchers; what each office person sees is their scope (Roles and scopes). A client company has owners, field managers and dispatchers, never techs.
To invite someone, create them with their email: CrewLink emails them an invite with a link and a sign-in code, and from then on they sign in with an emailed code (sign-in.md), no token needed. Send it again with POST /v1/office/users/{id}/invite. The token returned here is for integrations and devices.
The user object:
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | |
name |
string | |
email |
string or null | Used for email sign-in |
role |
string | owner, field_manager, dispatcher or tech |
personId |
integer or null | Techs: their person on the schedule |
active |
boolean | false once deactivated |
inviteSentAt |
string or null | When the last invite email went out (ISO instant); null if never |
scope |
string | company, team or own: which jobs and requests they see (Roles and scopes) |
scopeIsDefault |
boolean | true when scope is the role's default rather than set for them |
teamLeadId |
string or null | Dispatchers: the field manager whose team they're on |
phone |
string or null | From their invitation, or set by an owner |
#POST /v1/office/users
Add a user. Owner only. Returns their token once, as <user id>.<secret>; CrewLink keeps only a hash, so store it now. A CrewLink staff act-as session gets no token (the person signs in from the emailed invite).
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | 1 to 120 characters |
email |
string | no | A valid email. Needed for email sign-in |
role |
string | yes | owner, field_manager, dispatcher or tech (a client company: no tech, 422 invalid_role) |
personId |
integer | for techs | Someone on your team. Required when role is tech |
invite |
boolean | no | Email them an invite (see invite). Default true when CrewLink sends email; ignored without email |
scope |
string | no | company, team or own. Default: the role's |
teamLeadId |
string | no | Dispatchers: put them on this field manager's team (404 if it isn't an active field manager of yours) |
curl -s -X POST "$CREWLINK/v1/office/users" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Dana Office","email":"dana@northline.example","role":"dispatcher"}'Response 201:
{
"id": "b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35",
"name": "Dana Office",
"email": "dana@northline.example",
"role": "dispatcher",
"personId": null,
"active": true,
"inviteSentAt": "2026-10-12T15:30:00.000Z",
"scope": "company",
"scopeIsDefault": true,
"teamLeadId": null,
"phone": null,
"token": "b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35.<secret>",
"invited": true
}invited says whether the invite email went out. It's false when there's no email, invite was false, email isn't set up, you're past 5 invites a minute, or the email failed. The user is created either way; invite them later.
Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
Not an owner |
| 404 | not_found |
personId isn't on your team |
| 422 | invalid_request |
A tech without personId, or a field failed validation |
#GET /v1/office/users
Everyone who can sign in, including deactivated users. Field managers and dispatchers with a team or own scope see themselves and their team only.
Response 200:
{
"users": [
{ "id": "b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35", "name": "Dana Office", "email": "dana@northline.example", "role": "dispatcher", "personId": null, "active": true, "inviteSentAt": "2026-10-12T15:30:00.000Z", "scope": "company", "scopeIsDefault": true, "teamLeadId": null, "phone": null },
{ "id": "0d4f8a6e-2c1b-4f7a-9e3d-6b5c8a1e2f90", "name": "Maria Gomez", "email": null, "role": "tech", "personId": 1, "active": true, "inviteSentAt": null, "scope": "own", "scopeIsDefault": true, "teamLeadId": null, "phone": null }
]
}#POST /v1/office/users/{id}/invite
Email a user an invite. Owners invite anyone; field managers the dispatchers on their team; dispatchers techs (any other target is 403). The email says "<you> added you to <company> on CrewLink", their role and what it lets them do, and has a Sign in link to the CrewLink web app plus a 6-digit sign-in code already started for them. The link opens sign-in on the code step for their email, so the link and the code sign them in with no second step. Invited by the company API key, it reads "You've been added to <company> on CrewLink".
The code is an ordinary sign-in code (sign-in.md): it works for 10 minutes, once, with 5 tries, and counts toward 5 codes an hour. After that they ask for a new one from the link. If they've already had 5 codes this hour, the invite goes out without a code and says to ask for one. Calling it again resends: a new code, and the older one stops working.
Path: id (UUID) user id. No body.
curl -s -X POST "$CREWLINK/v1/office/users/b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35/invite" -H "Authorization: Bearer $KEY"Response 200: the user object, with inviteSentAt set to now.
{ "id": "b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35", "name": "Dana Office", "email": "dana@northline.example", "role": "dispatcher", "personId": null, "active": true, "inviteSentAt": "2026-10-12T15:30:00.000Z" }| Status | error |
When |
|---|---|---|
| 403 | forbidden |
A tech, or a dispatcher inviting someone who isn't a tech |
| 404 | not_found |
No user with that id at your company |
| 409 | inactive |
Their sign-in is turned off. Reactivate them first |
| 422 | no_email |
They have no email address |
| 429 | rate_limited |
More than 5 invites a minute for your company (including invites sent by POST /v1/office/users). Wait for Retry-After |
| 502 | mail_failed |
The email couldn't be sent. Nothing went out; try again |
| 503 | no_mailer |
Email isn't set up on this CrewLink server |
#POST /v1/office/team/invite-all
Owners only (not platform keys: 403). For every active person on the team who has an email (POST / PATCH /v1/office/team/{id}) and no sign-in user yet: adds a tech user linked to them with that email and emails them an invite, as POST /v1/office/users/{id}/invite does (a link and a sign-in code). People who already have a user are left alone; people with no email are counted so the office can add one. No body.
curl -s -X POST "$CREWLINK/v1/office/team/invite-all" -H "Authorization: Bearer $KEY"Response 200:
{ "created": 3, "invited": 3, "alreadyHadLogin": 5, "noEmail": 1, "emailInUse": 0, "failed": 0, "users": [ <user>, ... ] }| Field | Notes |
|---|---|
created |
Tech users added |
invited |
Invite emails sent |
alreadyHadLogin |
People who already have a user (invited or not): skipped |
noEmail |
Active people with no email |
emailInUse |
Skipped: someone at your company already signs in with that email |
failed |
Users added whose email failed; they're kept, invite them again from their row |
users |
The users added |
It counts as one invite against the 5-a-minute limit and runs at most twice a minute per company.
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
Not an owner, or a platform key |
| 422 | invalid_role |
A client company (it has no techs) |
| 429 | rate_limited |
Run more than twice a minute. Wait for Retry-After |
| 503 | no_mailer |
Email isn't set up on this CrewLink server (nothing is created) |
#POST /v1/office/users/{id}/token
Issue a new token for a user. Owner only. The old token stops working at once, every session the user started by signing in ends too, and their passkeys are removed: they're signed out everywhere and add passkeys again after their next code sign-in. Use it when a phone or a token may have leaked.
Path: id (UUID) user id. No body.
Response 200:
{ "token": "b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35.<secret>" }Errors: 403 forbidden (not an owner), 404 not_found, 422 invalid_request (id isn't a UUID).
#POST /v1/office/users/{id}/deactivate
Turn off a user. Owner only. Their token stops working, all their sessions end and their passkeys are removed.
Path: id (UUID) user id. No body.
Response 200: the user object, with "active": false.
Errors: 403 forbidden, 404 not_found, 422 invalid_request.
#POST /v1/office/users/{id}/reactivate
Turn a deactivated user back on. Owner only. Their existing token works again and they can sign in by email again. Sessions that ended and passkeys removed when they were deactivated stay gone. If their token may have leaked meanwhile, issue a new one with POST /v1/office/users/{id}/token.
Path: id (UUID) user id. No body.
Response 200: the user object, with "active": true.
Errors: 403 forbidden, 404 not_found, 422 invalid_request.
#GET /v1/me/tour and PATCH /v1/me/tour
Where the signed-in person is in each of CrewLink's in-app tours: the step they were on, and whether they finished or skipped it. CrewLink's own apps use it to start a tour once, on a person's first sign-in, and to resume it on any device. Any role (owner, dispatcher or tech, with a user or session token); each person sees and changes only their own. The company API key has no person behind it: GET answers { "tours": {}, "stored": false } and PATCH keeps nothing.
GET /v1/me/tour response 200:
{
"tours": {
"office": { "step": 4, "completedAt": null, "skippedAt": null, "updatedAt": "2026-10-12T15:31:00.000Z" }
},
"stored": true
}| Field | Notes |
|---|---|
tours |
One entry per tour the person has seen, by tour id (office, tech, partner, tech-app). No entry: never shown |
tours.*.step |
The step they were on, from 0 |
tours.*.completedAt, tours.*.skippedAt |
When they finished or skipped it (ISO instant), or null |
stored |
false for the company API key |
PATCH /v1/me/tour body:
| Field | Type | Notes |
|---|---|---|
tour |
string | Required. A lowercase word, up to 40 characters (office) |
step |
integer | Optional, 0 to 200 |
status |
string | Optional. active (started or resumed: clears completedAt and skippedAt), completed or skipped |
curl -X PATCH $CREWLINK/v1/me/tour -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"tour": "office", "step": 5}'Response 200: every tour, as GET returns them. Errors: 401 unauthorized, 422 invalid_request (a bad tour id, step or status, or a 21st tour for one person).
#GET /v1/me/prefs and PATCH /v1/me/prefs
Each person's own preferences: the time zone CrewLink shows them times in (unset: the company's), the theme, and a colour-vision filter. Any role (owner, dispatcher, tech), each person's own only. The response also says what kind of sign-in this is and when it ends.
curl -s -X PATCH "$CREWLINK/v1/me/prefs" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"timeZone":"America/Denver","theme":"blue","colorFilter":"deutan"}'Response 200 (GET and PATCH):
{
"prefs": { "timeZone": "America/Denver", "theme": "blue", "colorFilter": "deutan" },
"timeZone": "America/Denver",
"companyTimeZone": "America/Chicago",
"stored": true,
"session": { "kind": "session", "user": { "name": "Ann Lee", "email": "ann@example.com", "role": "dispatcher" }, "startedAt": "2026-10-12T15:30:00.000Z", "expiresAt": "2026-11-11T15:30:00.000Z" }
}| Field | Notes |
|---|---|
prefs.timeZone |
An IANA zone, or null for the company's. Send null to go back to it |
prefs.theme |
device (follow the device's light or dark setting), light, blue or dark |
prefs.colorFilter |
none, protan (red-weak), deutan (green-weak) or tritan (blue-weak): the apps swap status, chart and map colours for ones that stay apart, and give each status its own dot shape |
timeZone |
The zone to show times in: theirs, else the company's |
stored |
false for the company API key, which has no person: reads are the defaults, writes aren't kept |
session.kind |
session (signed in with an emailed code or a passkey; expiresAt says when it ends), user_token or api_key (last until replaced; expiresAt is null) |
API responses keep giving every time as an instant (start) and the company's wall clock (local); the zone is for showing them. Errors: 401 unauthorized, 422 invalid_request (a time zone CrewLink doesn't know, an unknown theme or filter).
#GET /v1/me/analytics
What CrewLink's own web and field apps tell product analytics (PostHog) about the signed-in person, so both send the same identity. Any role. No personal data: an id, the role, plan, trade, dates and flags; never a name, email or phone. Integrations have no use for it.
{
"enabled": true,
"reason": null,
"distinctId": "6f1c...-user-id",
"env": "production",
"person": { "role": "dispatcher", "company_id": "0b9e...", "plan": "crew", "trade": "hvac", "created_at": "2026-10-01" },
"company": { "id": "0b9e...", "properties": { "name": "Northline Heating & Air", "slug": "northline", "kind": "company", "plan": "crew", "plan_status": "trialing", "interval": "month", "trade": "hvac", "size": "6_10", "created_at": "2026-09-20", "is_demo": false, "is_test": false, "is_internal": false } },
"register": { "env": "production", "is_demo": false, "is_test": false, "is_internal": false }
}distinctId is the user id, or company:<id> for the company API key. enabled is false (and distinctId, person and company empty) during a super admin's act-as session (reason: "act_as") or when the company turned product analytics off (reason: "opted_out", productAnalytics). size is a bucket of active people (1, 2_5, 6_10, 11_25, 26_50, 51_plus); trade is one of the sign-up trades, other or unknown. Errors: 401 unauthorized.
#PATCH /v1/office/users/{id}
Change someone's role, scope or team. Owner only. Their sign-in keeps working; what they see changes on their next call.
Body (all optional):
| Name | Type | Notes |
|---|---|---|
role |
string | owner, field_manager, dispatcher or tech. A tech must already be linked to a person on the schedule; a client company has no techs |
scope |
string or null | company, team or own; null for the role's default. Ignored for owners (always company) and techs |
teamLeadId |
string or null | The field manager whose team a dispatcher is on, or null |
name |
string | 1 to 120 characters |
phone |
string or null |
Turning a field manager into another role takes their dispatchers off the team. Returns the user object. Errors: 403 forbidden (not an owner), 404 not_found (no such user, or teamLeadId isn't an active field manager of yours), 422 invalid_role / invalid_request.
curl -s -X PATCH "$CREWLINK/v1/office/users/b7e2c4d1-5a9f-4e3b-8c6d-1f0a2e9b7c35" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"role":"dispatcher","scope":"own","teamLeadId":"5c2d9e1f-7a4b-4c8d-9e0f-1a2b3c4d5e6f"}'#Roles and scopes
Four roles, and for office people a scope: which jobs and requests they see.
| Role | What they do | Default scope |
|---|---|---|
owner |
Everything, including users, company rules, clients and the plan | company, always |
field_manager |
Runs a team: the dispatchers whose teamLeadId is them. Invites dispatchers to the team |
team |
dispatcher |
Files and books work | company at your company; own at a client company |
tech |
The field app only (field-app.md), their own stops | (their own stops) |
| Scope | Sees |
|---|---|
company |
Every job and request of the company |
team |
Jobs and requests filed by them, their field manager and every dispatcher on that team |
own |
Only the jobs and requests they filed |
The same rule applies on both sides of a job: at the company doing the work, and at the client company that filed it. A job from the booking page, the company API key or an automatic path (recurring work, say) has no filer, so only company scope sees it.
People with a team or own scope:
- see other jobs on
GET /v1/office/scheduleas busy time only (hidden: true) and onGET /v1/office/dispatchas busy blocks (no live status there), and only their own onGET /v1/office/requests; - can move, cancel, accept or decline only jobs and requests in their scope (
404otherwise, so ids can't be probed); - see themselves and their team on
GET /v1/office/users, and the invitations they made; - may call only:
GET /v1/office/me,/schedule,/dispatch,/dispatch/routes(only techs with a stop of theirs),/team,/calendars,/requests(and accept or decline),POST /v1/office/first-available(New job's open times),POST /v1/office/jobs,/jobs/{id}/move,/cancel,GET /jobs/{id}/photos, the sites that hold one of their visits (GET /v1/office/sites,/sites/{id}), the intake queue and job search on their own jobs (GET /v1/office/intake,GET /v1/office/jobs,/jobs.csv,/jobs/{id},GET /v1/office/intake/{id}/options,POST /v1/office/intake/{id}/discard,POSTandDELETE /v1/office/jobs/{id}/attention; another person's job is404),GET /v1/office/users,POST /v1/office/users/{id}/invite, the invitation endpoints,GET /v1/connect/connections, a client connection's services, availability and requests (crewconnect.md),/v1/connect/requests, messaging,/v1/me/*and sign-in. Everything else (reports, KPIs, exports, money, settings, the live board, clients, webhooks, job notes, completing a job, a job's texts) answers403 scoped.
{ "error": "scoped", "message": "You see your own requests and jobs (or your team's). Ask an owner for this." }#Invitation links
Invite someone with a one-time link instead of (or as well as) an email with a code: https://go.crewlink.ai/join/<token>. Opening it shows who invited them, to which company and as what; Accept creates their sign-in and signs them in, and they add a passkey there or sign in later with an emailed code (sign-in.md). A link works once, for 7 days. CrewLink keeps only a fingerprint of the token: the link is shown when it's made and when you make a fresh one.
Who may invite whom:
| You are | You may invite |
|---|---|
| Owner | Any role into your company; owner, field_manager or dispatcher into a client company you set up (clientCompanyId) |
| Field manager | Dispatchers, onto your own team |
| Dispatcher | Techs (with personId) |
Email domains. Without clientCompanyId, an owner's invitation whose email domain is on one of your active client companies (emailDomains) goes into that client automatically (matchedBy: "email_domain"). Techs are never matched.
#POST /v1/office/invitations
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
role |
string | yes | owner, field_manager, dispatcher or tech |
name |
string | yes | 1 to 120 characters. They can correct it when they accept |
email |
string | yes | Their sign-in email |
phone |
string | no | 7 to 40 characters |
address |
string | no | Up to 300 characters |
clientCompanyId |
string | no | Into this client company (one you set up, owners only) |
teamOf |
string | no | Dispatchers: the field manager whose team they join (in the company they join). A field manager's own invitations default to their team |
personId |
integer | for techs | Who they are on the schedule |
send |
boolean | no | Email the link. Default: when CrewLink sends email |
curl -s -X POST "$CREWLINK/v1/office/invitations" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"role":"dispatcher","name":"Dana One","email":"dana@globexsolar.example","teamOf":"5c2d9e1f-7a4b-4c8d-9e0f-1a2b3c4d5e6f"}'Response 201:
{
"id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"role": "dispatcher",
"name": "Dana One",
"email": "dana@globexsolar.example",
"phone": null,
"company": { "id": "9b1e6f2a-4c3d-4e8f-a7b6-2d1c0e9f8a75", "name": "Globex Solar" },
"client": true,
"matchedBy": "email_domain",
"teamLeadId": "5c2d9e1f-7a4b-4c8d-9e0f-1a2b3c4d5e6f",
"status": "pending",
"invitedBy": null,
"emailed": true,
"expiresAt": "2026-10-19T15:30:00.000Z",
"createdAt": "2026-10-12T15:30:00.000Z",
"acceptedAt": null,
"link": "https://go.crewlink.ai/join/Qm4pX9…"
}A newer invitation for the same email and company replaces a pending one (the old link stops). Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
Your role can't invite that role, or there (see the table above) |
| 404 | not_found |
clientCompanyId isn't a client you set up; teamOf isn't an active field manager there; personId isn't on your team |
| 409 | already_member |
That email already signs in to that company |
| 422 | invalid_role |
A tech into a client company |
| 422 | invalid_request |
A tech without personId; teamOf for a role other than dispatcher |
#GET /v1/office/invitations
Invitations you made (into your company or your client companies) and ones made into your company, newest first. ?status=all adds accepted, revoked and expired ones (default: pending only). Field managers and dispatchers with a team or own scope see the ones they made. Each has status: pending, accepted, revoked or expired.
#POST /v1/office/invitations/{id}/revoke
The link stops working. Owners, or whoever made it. 409 accepted once they've joined (turn off their sign-in instead).
#POST /v1/office/invitations/{id}/resend
A fresh link, valid 7 days from now, emailed again when CrewLink sends email; the old link stops working. Works on pending, expired and revoked invitations. Returns the invitation with link. 409 accepted once they've joined.
#Client companies
A client company files service requests into yours and follows them: an installer that books site surveys with a survey company, for example (the old CrewLink's "scheduling companies"). It has its own sign-ins (owners, field managers, dispatchers; no techs and no plan of its own) and sees only the requests it filed, with their status, time and the tech's first name, and your open times. Never your other jobs, customers or people.
Its people file requests with POST /v1/connect/connections/{id}/requests and list them with GET /v1/connect/requests. Those requests reach you on GET /v1/office/requests (pending) or straight on the schedule (booked at one of your open times), marked with the client and who filed them, and through the request.created and booking.created webhooks. You can also book a job for a client yourself (clientCompanyId). Dispatch, Requests and KPIs take a client filter.
Its sales reps can also book with no account at all, from a booking link you (or your platform) make for the client: see Client share links (/v1/office/clients/{clientId}/share-links). Those jobs read source: "share_link" and bookedBy (the rep), and filedBy says "Booked by Rita Rep via link · Globex Solar". GET /v1/office/clients/{id} gives each of its latest jobs bookedBy too.
There are two ways to have a client:
- You set it up (
managed: true):POST /v1/office/clients, then invite its people with invitation links. You can rename it, set its email domains, issue its API key and turn it off. - A CrewLink company connects to you as a client (
managed: false): it callsPOST /v1/connect/connectionswithkind: "client"(crewconnect.md) and you accept it (here withstatus: "active", or with the usual accept call). Its people are its own.
The client object:
| Field | Type | Notes |
|---|---|---|
id |
string | The client company's id: use it as clientCompanyId and in client filters |
connectionId |
string | The client connection (the client files requests on it) |
name |
string | |
kind |
string | client (set up by you) or company (its own CrewLink account) |
managed |
boolean | You set it up |
status |
string | active, inactive (turned off) or pending (asked to connect, waiting for you) |
emailDomains |
string[] | Invitees with these email domains join it automatically |
fieldManagers, dispatchers |
integer | Active sign-ins |
openRequests |
integer | Filed and not scheduled yet |
jobs |
integer | Every request and job for this client, any status |
apiKey |
boolean | Its API key has been issued (managed clients only) |
apiKeyIssuedAt |
string or null | When |
createdAt |
string |
#GET /v1/office/clients
Your client companies, by name. Owners, field managers and dispatchers with company scope.
#POST /v1/office/clients
Set up a client company. Owner only. It starts active, already connected to you, with company visibility (it sees open times and capacity, no names).
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | 1 to 200 characters |
emailDomains |
string[] | no | Up to 10, like globexsolar.example (a leading @ is dropped, case ignored) |
timezone |
string | no | IANA zone. Default: yours |
curl -s -X POST "$CREWLINK/v1/office/clients" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Globex Solar","emailDomains":["globexsolar.example"]}'Response 201: the client object. 409 domain_taken when another of your clients already uses one of the domains; 422 for a client company calling it.
#GET /v1/office/clients/{id}
The client object plus people (its sign-ins with role, scope and team; empty for a company that connected itself), jobs (the latest 50 requests and jobs, newest first: jobId, status as the client sees it, service, customer, address, local, filedBy, createdAt) and its pending invitations.
#PATCH /v1/office/clients/{id}
Owner only. Body (all optional): name (clients you set up only, else 403 not_yours), emailDomains (replaces the list; 409 domain_taken), status: inactive ends the client connection (its people can still sign in and read the requests they filed, but can't file new ones or see your open times; the client gets connection.ended), active turns it back on or accepts a client that asked to connect (connection.accepted). Returns the client object.
#POST /v1/office/clients/{id}/api-key
Issue the client's API key. Owner only, clients you set up (403 not_yours otherwise). Returns { "key": "<client company id>.<secret>", "issuedAt": "…" } once; the previous key stops working. The client's own systems use it to file and read requests (crewconnect.md), as the client's owner: it sees nothing of your schedule.
#Test clients and test people
A client company can be marked test (PATCH /v1/office/clients/{id} { "test": true }, owner only, companies of kind client), and so can people (PATCH /v1/office/team/{id} { "test": true }, office users and platform keys). Reads show test on client rows, on the person object, on GET /v1/office/schedule techs and visits and on GET /v1/office/dispatch lanes, stops and queue rows (the web board shows a Test badge).
The rule is a strict split:
- A job for a test client (its
clientCompanyIdis a test client) is only ever offered, booked or moved to test people. - Every other job is never offered, booked or moved to a test person.
It holds everywhere a tech is chosen: POST /v1/office/availability, POST /v1/office/first-available and POST /v1/office/holds (send clientCompanyId for a client's job; left out, the job is not a test client's), POST /v1/office/jobs (with or without techIds, holds booked with it, and history imports), POST /v1/office/jobs/{id}/move, the engine's own picks, the intake options, running-late hand-offs and pick-a-new-time offers, a client's own requests and open times (/v1/connect/connections/{id}/requests and /availability), CrewConnect sends and trips. Naming someone on the wrong side is 409 slot_taken with reason: "not_on_team" and a message that says so (422 for a history import). A hold made for one side can't be booked for the other (409 slot_taken, hold again with clientCompanyId).
Test people are left out of KPIs (with the jobs they work and test clients' jobs, and the money, reviews and pay of those jobs), the Wins team numbers and leaderboard, the pay report and the summary report, unless asked: includeTest=true on GET /v1/office/kpis, GET /v1/office/pay and GET /v1/office/exports/pay.csv (or personId of that test person on the pay report). Their own field-app wins still count.
#Out-of-area requests and trips
The booking page offers no times at addresses outside every service area. Instead, the customer files a request with up to 3 days that suit them (booking-page.md) and is told the office will confirm. The office then either accepts each request for a time (optionally with a travel fee), declines it, or groups several into a trip: CrewLink picks the person and free days that earn the most after hotel, per diem and mileage, and drops trips that would lose money.
Requests filed by your client companies without a time (or at a time nobody could make) wait here too, marked with the client and who filed them. Accept or decline them the same way; the client sees the time and the tech's first name once you book it.
Request ids are job ids: once accepted or booked on a trip, the same id is the job.
#GET /v1/office/requests
Requests waiting for an answer (out-of-area and client-filed), oldest first. Query client: a client company id for only its requests, or none for requests with no client. People with a team or own scope see only requests in their scope. Each comes with up to 3 workable times on the days the customer asked for (anyone qualified, ignoring service areas), least added driving first, then sorted by time.
Response 200:
{
"requests": [
{
"requestId": 1402,
"customer": "Jamie Cruz",
"service": "Seasonal tune-up",
"address": "1 Lake Dr, Milwaukee",
"timezone": "America/Chicago",
"km": 122.6,
"requested": [{ "date": "2026-10-20", "part": "afternoon" }],
"options": [
{ "start": "2026-10-20T17:00:00.000Z", "local": "2026-10-20T12:00", "techs": [{ "id": 1, "name": "Maria Gomez" }], "driveMinutes": 122, "addedDriveMinutes": 244 },
{ "start": "2026-10-20T18:15:00.000Z", "local": "2026-10-20T13:15", "techs": [{ "id": 1, "name": "Maria Gomez" }], "driveMinutes": 125, "addedDriveMinutes": 251 }
],
"createdAt": "2026-10-12T15:31:04.000Z",
"client": null,
"filedBy": null,
"source": "booking_page",
"notes": null
}
]
}| Field | Notes |
|---|---|
timezone |
The zone of the requested days and the options: the nearest branch's |
km |
Straight-line distance from your nearest branch, in km (show it in your units) |
client |
{ id, name } of the client company that filed it, or null |
filedBy |
Ready to show, e.g. Filed by Dana One · Globex Solar; null for booking-page requests |
source |
booking_page, client (a client's person), api (a client's API key) or office |
notes |
What the customer or client wrote |
requested[].part |
morning (7:00 to 12:00), afternoon (12:00 to 18:00) or any |
options |
May be empty when nothing fits on those days, or the days have passed. Times start at least an hour from now |
options[].driveMinutes |
One-way drive to the job from where the tech sets off: their previous stop that day, else home (or the trip base on a trip day). For a crew, the longest. A rough road estimate; show this as "the drive" |
options[].addedDriveMinutes |
Extra driving that day: how much longer the tech's whole day of driving gets with this stop, out and back, compared with not taking it. For a far-away job it's about twice driveMinutes |
#POST /v1/office/requests
File a request for a visit ("request a visit", or an address outside every service area) the way the booking page does (POST /v1/public/{slug}/requests), as the office or a platform key (write), and say whose it is. It waits here (status: "waiting") with the days the customer picked until you accept it, decline it or book it on a trip.
Body: the booking page's (serviceId, lat, lng, customer { name, phone, email? }, address, notes, requested: 1 to 3 { date, part } with part morning, afternoon or any), plus:
| Name | Type | Required | Notes |
|---|---|---|---|
clientCompanyId |
string | no | One of your client companies, active (404 otherwise, as on POST /v1/office/jobs). The request is that client's: it reads it on GET /v1/connect/requests (pending) like one it filed, and GET /v1/office/requests and request.created carry client |
externalRefs, or externalRef with externalSource |
no | Your ids for it (a platform's source defaults to its slug). 409 duplicate_external_ref with jobId when one is on a job that isn't cancelled |
|
notify |
boolean | no | false: the homeowner gets nothing about this job, ever. Otherwise they get the request-received text unless the company's or the platform grant's homeowner messaging is off |
curl -s -X POST "$CREWLINK/v1/office/requests" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"serviceId":41,"lat":39.95,"lng":-75.16,"address":"1500 Market St, Philadelphia, PA 19102","customer":{"name":"Pat Quinn","phone":"215 555 0177"},"requested":[{"date":"2026-10-20","part":"morning"}],"clientCompanyId":"6d1c...","externalRef":"appt_901"}'Response 201: { "requestId": 1402, "status": "waiting", "timezone": "America/New_York", "client": { "id": "6d1c...", "name": "Initech Solar" }, "externalRefs": { "acme": "appt_901" } }. Sends request.created (with source platform, office or api, client and filedBy). 422 from a client company's own key (it files with POST /v1/connect/connections/{id}/requests); 404 for an unknown service or client company.
#POST /v1/office/requests/{id}/accept
Book a waiting request for a time. Anyone qualified may go, regardless of service areas, under the same locks as any booking. The travel fee, if any, appears in the customer's confirmation text and as a line on the job's invoice.
Path: id (integer) request id.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
start |
string | one of | ISO datetime with offset; not in the past. Usually one of the options[].start values |
local |
string | one of | Instead of start: the wall clock in the request's timezone, YYYY-MM-DDTHH:mm |
techIds |
array of integers | no | At least one. Preferred people: they go if they fit, otherwise CrewLink picks |
tripFeeCents |
integer | no | 0 to 1,000,000. Travel fee. Nothing is saved when the accept fails |
curl -s -X POST "$CREWLINK/v1/office/requests/1402/accept" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"start":"2026-10-20T17:00:00.000Z","tripFeeCents":7500}'Response 200:
{
"jobId": 1402,
"local": "2026-10-20T12:00",
"timezone": "America/Chicago",
"techs": [{ "id": 1, "name": "Maria Gomez" }],
"tripFeeCents": 7500
}The customer is texted their confirmation with a manage link. Sends booking.created with source: "out_of_area_request".
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No waiting request with that id (already accepted, declined or booked on a trip) |
| 409 | in_the_past |
start has passed |
| 409 | cannot_reach |
No one qualified can get there by then, or the drive and their other jobs that day are in the way. For example, a 3-hour drive before an 11:00 AM start means leaving before an 8:00 AM day starts |
| 409 | slot_taken |
Everyone qualified is ruled out by the on-call window, their hours or time off; reason says which (see Why a time can't be booked) |
| 422 | invalid_request |
A field failed validation |
Both 409s carry earliest: the earliest time that works that day (anyone qualified, ignoring service areas), or null when nothing does. The message says it too. For other days, the request's options in GET /v1/office/requests list workable times.
{
"error": "cannot_reach",
"reason": "doesnt_fit",
"personId": null,
"message": "The nearest qualified tech, Maria Gomez, is about a 4 h 2 min drive away, so a 9:00 AM start means leaving around 4:58 AM, before their day starts at 8:00 AM. The earliest that works that day is 12:15 PM (Maria Gomez). The request's options list other workable times.",
"earliest": { "start": "2026-10-20T17:15:00.000Z", "local": "2026-10-20T12:15", "techs": [{ "id": 1, "name": "Maria Gomez" }] }
}#POST /v1/office/requests/{id}/decline
Decline a waiting request. The customer gets a polite text, with your message added.
Path: id (integer) request id.
Body (optional):
| Name | Type | Required | Notes |
|---|---|---|---|
message |
string | no | Up to 300 characters, e.g. a referral: "Try Lakeside HVAC in Milwaukee." |
Response 200:
{ "requestId": 1403, "status": "declined" }Errors: 404 not_found (no waiting request with that id), 422 invalid_request.
#GET /v1/office/trips/suggestions
Trips CrewLink suggests for the waiting requests. Each trip is one person away for 1 or more whole free days, based near the requests, with every stop timed. Costs are per trip: hotel and per diem per night, mileage per km. A request is valued at its service's list price. Trips that lose money aren't suggested.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
from |
string | no | YYYY-MM-DD, first day a trip may start. Default tomorrow |
to |
string | no | YYYY-MM-DD, last day, on or after from. Default 13 days after from. At most 92 days from from to to |
hotelCents |
integer | no | Per night, >= 0. Default 13900 |
perDiemCents |
integer | no | Per night, >= 0. Default 5900 |
perKmCents |
integer | no | >= 0. Default 40 |
radiusKm |
number | no | 5 to 100. How far stops may be from the trip's base. Default 30 |
curl -s "$CREWLINK/v1/office/trips/suggestions?from=2026-11-02&to=2026-11-13" -H "Authorization: Bearer $KEY"Response 200:
{
"waiting": 5,
"trips": [
{
"personId": 1,
"tech": "Maria Gomez",
"firstDay": "2026-11-03",
"lastDay": "2026-11-03",
"base": { "lat": 43.06, "lng": -87.91 },
"areaKm": 3.4,
"nights": 0,
"km": 254,
"costCents": 10160,
"valueCents": 64500,
"stops": [
{ "jobId": 1410, "start": "2026-11-03T08:00", "end": "2026-11-03T09:15", "customer": "Jamie Cruz", "address": "1 Lake Dr, Milwaukee" },
{ "jobId": 1411, "start": "2026-11-03T09:30", "end": "2026-11-03T10:45", "customer": "Sam Ortiz", "address": "2 Lake Dr, Milwaukee" },
{ "jobId": 1412, "start": "2026-11-03T11:00", "end": "2026-11-03T12:15", "customer": "Lee Park", "address": "3 Lake Dr, Milwaukee" },
{ "jobId": 1413, "start": "2026-11-03T13:00", "end": "2026-11-03T14:15", "customer": "Ana Ruiz", "address": null },
{ "jobId": 1414, "start": "2026-11-03T14:30", "end": "2026-11-03T15:45", "customer": "Chris Doe", "address": "5 Lake Dr, Milwaukee" }
],
"handoffs": [
{ "jobId": 1198, "date": "2026-11-03", "toPersonId": 3 }
]
}
]
}| Field | Notes |
|---|---|
waiting |
How many requests are waiting |
base, areaKm |
Where the trip is based and how far it reaches. On trip days the tech starts and ends at the base and serves this area instead of their usual one, so homeowners there can book into the gaps on the booking page |
costCents |
Hotel, per diem and mileage for the whole trip |
valueCents |
List price of the stops |
stops[] |
Local wall clock, with the request's customer (name) and address (null when the customer gave none). Booking the trip needs only jobId and start per stop; sending the stops back as returned is fine |
handoffs[] |
The person's in-area jobs on trip days that go to a teammate at the same time, to free the trip days |
#POST /v1/office/trips
Book a trip, usually a suggestion sent back as returned. CrewLink locks the trip days, hands the listed in-area jobs to teammates (same times), checks the person's days are free and every stop fits, then books each stop and texts each customer. If anything changed since the suggestion, nothing is booked and you get 409.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
personId |
integer | yes | Who travels |
firstDay |
string | yes | YYYY-MM-DD |
lastDay |
string | yes | YYYY-MM-DD; a trip is 1 to 7 days |
base |
object | yes | { "lat": number, "lng": number } |
areaKm |
number | yes | 1 to 200 |
costCents |
integer | no | >= 0, default 0. Recorded for reports |
stops |
array | yes | 1 to 40 of { "jobId": integer, "start": "YYYY-MM-DDTHH:mm" }. jobId is a waiting request; start is local wall clock |
handoffs |
array | no | Up to 30 of { "jobId": integer, "date": "YYYY-MM-DD", "toPersonId": integer }. Default none |
curl -s -X POST "$CREWLINK/v1/office/trips" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"personId": 1,
"firstDay": "2026-11-03", "lastDay": "2026-11-03",
"base": { "lat": 43.06, "lng": -87.91 }, "areaKm": 3.4, "costCents": 10160,
"stops": [
{ "jobId": 1410, "start": "2026-11-03T08:00" },
{ "jobId": 1411, "start": "2026-11-03T09:30" }
],
"handoffs": [{ "jobId": 1198, "date": "2026-11-03", "toPersonId": 3 }]
}'Response 201:
{ "tripId": "9a3d7c1e-4b2f-4e8a-b6c5-0d1e2f3a4b5c", "booked": 2 }Webhook events: trip.booked, booking.created with source: "trip" and tripId for each stop, and booking.rescheduled with source: "trip_handoff" for each handoff.
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
personId isn't on your team, or some stops aren't waiting requests any more (gone or already booked) |
| 409 | changed |
The schedule changed: those days aren't free, a stop no longer fits, or a handoff can't be made. Nothing is booked or handed off. Get fresh suggestions |
| 422 | invalid_request |
More than 7 days, lastDay before firstDay, or a field failed validation |
#GET /v1/office/trips
Booked trips, earliest first.
Response 200:
{
"trips": [
{ "id": "9a3d7c1e-4b2f-4e8a-b6c5-0d1e2f3a4b5c", "personId": 1, "firstDay": "2026-11-03", "lastDay": "2026-11-03", "base": { "lat": 43.06, "lng": -87.91 }, "costCents": 10160 }
]
}#Recurring work and memberships
A service every N months at one address, such as a maintenance plan or membership. CrewLink books each visit up to 14 days ahead, within 3 days either side of its due date, at the open time that adds the least driving (it rides along with jobs already nearby). The customer is texted the time with a link to change it, and the due date moves on by the interval. This runs every hour on its own.
Service area. The same rule as New job: an address no one's area covers (no branch's areaKm, no radius of someone without a branch) is never booked. Its visit goes to the intake queue instead: status queued, source recurring, intakeReason and the needs-attention flag "Out of service area" (job.needs_attention goes out), with no text to the customer, and the due date moves on. The office places it from Intake (anyone qualified is suggested; book it with move and techIds), or stops the series. While it waits, the series queues no other visit.
The recurrence object:
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | |
customer |
string | Customer name |
service |
string | Service name |
everyMonths |
integer | |
nextDue |
string | YYYY-MM-DD of the next visit not yet booked |
membership |
string or null | e.g. "Comfort Club" |
priceCents |
integer or null | Membership price per period |
active |
boolean | |
address |
string or null |
#POST /v1/office/recurring
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
customer.name |
string | yes | 1 to 200 characters |
customer.phone |
string | yes | 7 to 40 characters |
customer.email |
string | no | A valid email |
serviceId |
integer | yes | One of your active services |
lat |
number | yes | -90 to 90 |
lng |
number | yes | -180 to 180 |
address |
string | no | Up to 300 characters |
everyMonths |
integer | yes | 1 to 24 |
firstDue |
string | yes | YYYY-MM-DD |
membership |
string | no | Up to 100 characters |
priceCents |
integer | no | >= 0. Membership price per period |
curl -s -X POST "$CREWLINK/v1/office/recurring" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"customer": { "name": "Jamie Cruz", "phone": "(312) 555-0188" },
"serviceId": 1, "lat": 41.955, "lng": -87.675, "address": "9 Oak St",
"everyMonths": 6, "firstDue": "2026-10-20",
"membership": "Comfort Club", "priceCents": 14900
}'Response 201:
{
"id": "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"customer": "Jamie Cruz",
"service": "Seasonal tune-up",
"everyMonths": 6,
"nextDue": "2026-10-20",
"membership": "Comfort Club",
"priceCents": 14900,
"active": true,
"address": "9 Oak St"
}Errors: 404 not_found (no such service, or it's turned off), 422 invalid_request.
Recurrences of a service that is later turned off aren't booked while it's off.
#GET /v1/office/recurring
Response 200: { "recurring": [ <recurrence>, ... ] }.
#POST /v1/office/recurring/{id}/stop
Stop a recurrence. Visits already booked stay booked.
Path: id (UUID). No body.
Response 200:
{ "id": "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", "active": false }Errors: 404 not_found, 422 invalid_request (id isn't a UUID).
#POST /v1/office/recurring/run
Book every active recurring visit due in the next 14 days now, instead of waiting for the hourly run. Safe to call repeatedly: a recurrence that already has an upcoming visit booked is skipped.
No body.
Response 200:
{
"booked": [
{ "recurrenceId": "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", "jobId": 1517, "local": "2026-10-19T09:30" }
],
"unplaced": [
{ "recurrenceId": "7a1b2c3d-4e5f-4a6b-9c8d-7e6f5a4b3c2d", "due": "2026-10-22" }
],
"outOfArea": [
{ "recurrenceId": "0b9c8d7e-6f5a-4b3c-2d1e-0f9a8b7c6d5e", "jobId": 1521, "due": "2026-10-20" }
]
}unplaced lists recurrences with no workable time within 3 days of their due date; they're tried again on the next run. outOfArea lists visits outside every service area: not booked, queued as jobId with the flag "Out of service area", nobody texted (see above). Each booked visit sends booking.created with source: "recurring".
#Inventory
Parts and where they are: the warehouse, or a tech's truck. Techs log parts used on a job from the field app, which takes them off their truck and adds them to the job's invoice and P&L (field-app.md).
Holders: 0 is the warehouse; any other value is a person id (their truck).
The part object:
| Field | Type | Notes |
|---|---|---|
id |
integer | |
sku |
string | Unique within your company |
name |
string | |
unitCostCents |
integer | What it costs you |
priceCents |
integer | What you charge |
reorderAt |
integer | Reorder point |
onHand |
integer | Total across the warehouse and every truck |
low |
boolean | onHand is at or under reorderAt |
#POST /v1/office/parts
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
sku |
string | yes | 1 to 60 characters |
name |
string | yes | 1 to 200 characters |
unitCostCents |
integer | yes | >= 0 |
priceCents |
integer | yes | >= 0 |
reorderAt |
integer | no | >= 0, default 0 |
curl -s -X POST "$CREWLINK/v1/office/parts" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"sku":"FLT-1620","name":"16x20 filter","unitCostCents":600,"priceCents":2500,"reorderAt":5}'Response 201:
{ "id": 21, "sku": "FLT-1620", "name": "16x20 filter", "unitCostCents": 600, "priceCents": 2500, "reorderAt": 5, "onHand": 0, "low": true }Errors: 409 exists (a part with that SKU exists), 422 invalid_request.
#GET /v1/office/parts
Active parts, by SKU.
Response 200:
{
"parts": [
{ "id": 21, "sku": "FLT-1620", "name": "16x20 filter", "unitCostCents": 600, "priceCents": 2500, "reorderAt": 5, "onHand": 8, "low": false }
]
}#PUT /v1/office/stock
Set a count after a stock take. Replaces the quantity at that holder.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
partId |
integer | yes | One of your parts |
holder |
integer | yes | 0 for the warehouse, or a person id |
qty |
integer | yes | >= 0 |
Response 200 echoes the body:
{ "partId": 21, "holder": 0, "qty": 10 }Errors: 404 not_found (no such part, or no one with that id on your team), 422 invalid_request.
#POST /v1/office/stock/transfer
Move stock: warehouse to truck, truck to truck, or back.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
partId |
integer | yes | One of your parts |
from |
integer | yes | Holder: 0 or a person id |
to |
integer | yes | Holder: 0 or a person id |
qty |
integer | yes | >= 1 |
curl -s -X POST "$CREWLINK/v1/office/stock/transfer" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"partId":21,"from":0,"to":1,"qty":4}'Response 200, the quantities at each holder after the move:
{ "from": 6, "to": 4 }Errors: 404 not_found (no such part or person), 409 not_enough (fewer than qty at from), 422 invalid_request.
#Job P&L
#GET /v1/office/jobs/{id}/pnl
Profit and loss for one job.
- Revenue: the job's latest invoice (before tax) if there is one, otherwise the service list price plus parts at price plus any travel fee.
- Parts: parts used, at cost.
- Labor: each person's loaded hourly cost for their time on site (as tapped in the field app, else as booked) plus the drive in from their previous stop that day (or from home). People without a set hourly cost count at $35.00 an hour.
- Vehicle: cost per km of the drive in, from the vehicle assigned to that person (40 cents per km if none).
- Platform fee: CrewLink's 1% on customer payments collected through CrewLink for this job.
Path: id (integer) job id.
curl -s "$CREWLINK/v1/office/jobs/1620/pnl" -H "Authorization: Bearer $KEY"Response 200:
{
"jobId": 1620,
"revenueCents": 17900,
"costs": { "partsCents": 1200, "laborCents": 4317, "vehicleCents": 310, "platformFeeCents": 0 },
"profitCents": 12073,
"marginPct": 67.4,
"basis": { "revenue": "invoice", "laborMinutes": 74, "driveKm": 6.2 }
}| Field | Notes |
|---|---|
marginPct |
Profit over revenue, one decimal. null when revenue is 0 |
basis.revenue |
invoice or list_price |
basis.laborMinutes |
Person-minutes counted for labor (on site plus drive in, every crew member) |
basis.driveKm |
Estimated road distance of the drives in |
The KPIs avg_ticket, labor_cost_pct and profit_margin use this same calculation over every completed job in a range.
Errors: 404 not_found.
#Fleet
#POST /v1/office/vehicles
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | 1 to 100 characters, e.g. "Van 3" |
plate |
string | no | Up to 20 characters |
personId |
integer | no | Who drives it |
costPerKmCents |
integer | no | >= 0, default 40. Fuel and wear, used in job P&L |
odometerKm |
integer | no | >= 0 |
curl -s -X POST "$CREWLINK/v1/office/vehicles" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"name":"Van 3","plate":"IL 123","personId":1,"costPerKmCents":50}'Response 201:
{ "id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f" }Errors: 404 not_found (personId isn't on your team), 422 invalid_request.
#GET /v1/office/fleet
Active vehicles, who has them, and where they are. The position is the driver's last report from the field app.
Response 200:
{
"vehicles": [
{
"id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"name": "Van 3",
"plate": "IL 123",
"personId": 1,
"tech": "Maria Gomez",
"costPerKmCents": 50,
"location": { "lat": 41.9532, "lng": -87.6771, "at": "2026-10-16T15:02:41.000Z" }
}
]
}personId, tech and location are null for an unassigned vehicle; location is null until the driver's phone reports.
#Drive times (travel model)
Every open time, assignment and move counts the drive at that hour of the day. Drive times come from real road data (OpenStreetMap) where CrewLink has it for your area, times a rush-hour curve that CrewLink keeps learning for each large US metro from traffic-aware lookups. Elsewhere they fall back to a distance-based estimate. This endpoint shows which is in use; nothing in it is specific to another company.
#GET /v1/ops/travel
Owner only (and CrewLink super admins, admin.md).
curl -s $CREWLINK/v1/ops/travel -H "Authorization: Bearer $KEY"Response 200:
{
"router": { "kind": "osrm", "regions": [{ "key": "illinois", "dataVersion": "illinois-20261011", "bbox": [-91.51, 36.97, -87.02, 42.51] }, { "key": "colorado", "dataVersion": "colorado-20261011", "bbox": [-109.06, 36.99, -102.04, 41.0] }], "dataVersion": "illinois-20261011,colorado-20261011", "datasetDate": "2026-10-11", "lastOkAt": "2026-10-12T15:02:41.000Z", "lastError": null, "resting": false },
"cache": { "hits": 18234, "misses": 1210, "hitRate": 0.938, "size": 1210, "capacity": 200000, "requests": 96, "errors": 0 },
"lastAttach": { "at": "2026-10-12T15:02:40.912Z", "ms": 84, "points": 61, "pairs": 233, "missing": 0 },
"calibration": { "enabled": true, "reason": null, "usedToday": 512, "cap": 2000, "lastRunAt": "2026-10-12T15:00:00.000Z", "lastRunLookups": 84 },
"yourMetro": "us-chicago",
"yourRegions": [{ "branch": "North", "region": "us-chicago", "name": "Chicago" }, { "branch": "Denver", "region": "us-denver", "name": "Denver" }],
"metros": [{ "metro": "us-chicago", "name": "Chicago", "version": 3, "observations": 4120, "updatedAt": "2026-10-12T15:00:03.000Z" }]
}router.kind:osrm(road data) orstraight_line(the distance-based estimate).router.regionslists the road datasets (one per region the server serves, e.g. two states or two countries, each with its bounding box[minLng, minLat, maxLng, maxLat]); a drive is priced by the smallest dataset holding both ends, and by the estimate when none does.datasetDateis when the road data was built.restingistruefor up to 30 seconds after repeated failures, while the estimate stands in.cache: road lookups answered from memory (hits) versus asked of the router (misses);hitRateisnullbefore the first lookup.lastAttach: the latest schedule search: how long fetching road times took and how many trips it covered (missing: trips priced by the estimate instead).calibration: today's (UTC) traffic lookups that teach the rush-hour curve, against the daily cap.enabledisfalsewith areasonwhen it is not running.yourMetro: your main branch's region.yourRegions: every branch's. A region is one of ~390 listed metros worldwide (us-chicago,gb-london,de-berlin,au-sydney), or outside them a ~50 km grid cell (cell-<row>-<col>). Each branch plans with its own region's rush-hour curve (a built-in curve until its region has learned one). Listed metros are taught by traffic lookups; cells learn from real drives.metros: the latest learned curve per region:versiongoes up once a day while it is learning. (Before 2026-10-12 region keys had no country:chicagois nowus-chicago.)
Errors: 401 unauthorized, 403 forbidden (dispatchers and techs).
#GET /v1/ops/brain
Owner only (and CrewLink super admins). CrewLink tunes its scheduling per metro with a simulator: it replays days in each large US metro (real building locations, road times and rush hours, plus real finished days from Plan vs actual, anonymised: map cells and minutes only, never names or addresses) and tries different settings (minutes of buffer before each visit, a margin on drive times, how full a tech's day may get, when a running-late customer is handed to a teammate). A metro gets new settings only when they beat the current ones on the same simulated days and keep every one of the engine's guarantees (no double-booking, 95% on time, and the rest). Your schedule uses your metro's settings within the hour they are published; nothing changes in requests or responses. This endpoint shows the training; nothing in it is specific to another company.
curl -s $CREWLINK/v1/ops/brain -H "Authorization: Bearer $KEY"Response 200:
{
"store": { "kind": "s3", "error": null },
"yourMetro": "us-chicago",
"runs": [
{
"runId": "cl-<run id>", "version": "202610111315", "sha": "4f2c9e1", "publishedAt": "2026-10-11T13:31:02Z", "seconds": 812,
"metros": [{ "metro": "us-chicago", "name": "Chicago", "version": "202610111315", "changed": true, "scoreBefore": 3.112, "scoreAfter": 3.187, "delta": 0.075, "onTimeBefore": 0.952, "onTimeAfter": 0.968, "realDays": 14 }]
}
],
"params": [{ "metro": "us-chicago", "name": "Chicago", "version": "202610111315", "changed": true, "params": { "buffer_min": 7, "drive_pad_pct": 5, "max_detour_min": 20, "load_weight": 2, "max_jobs_per_day": 0, "max_booked_pct": 100, "on_call_start": 720, "on_call_end": 1020, "late_handoff_min": 10, "handoff_radius_km": 0 }, "checkedAt": "2026-10-11T14:00:00.000Z", "error": null }],
"realDays": { "total": 14, "byMetro": { "us-chicago": 14 }, "lastAt": "2026-10-12T09:00:04.000Z" }
}store.kind: where training data lives (s3), ornullwhen it isn't set up on this server: then every company plans with the built-in settings.runs: the last 10 training runs, newest first. Per metro: the score before and after (on-time visits per tech-day, net of driving and broken promises; higher is better), the on-time rate in the simulated days, how many real days took part, and whether new settings were published (changed).params: the settings each metro plans with right now.versionisnullwhen that metro has no trained settings (the built-in ones apply).on_call_start/on_call_endare what the simulator found best for an on-call window; your company's own on-call setting always wins.realDays: finished days exported for training so far, by metro.
Errors: 401 unauthorized, 403 forbidden (dispatchers and techs).
#Reports and market data
#GET /v1/office/reports
KPIs for a date range, from the live schedule and the field app. This is the first, fixed-shape summary and stays for existing integrations; GET /v1/office/kpis has more KPIs, the prior period, daily series and branches.
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
from |
string | yes | YYYY-MM-DD |
to |
string | yes | YYYY-MM-DD, inclusive, on or after from. At most 92 days in all |
curl -s "$CREWLINK/v1/office/reports?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"Response 200:
{
"from": "2026-10-01",
"to": "2026-10-31",
"jobs": { "booked": 212, "done": 187, "cancelled": 9, "waiting": 2, "sentToPartners": 6, "fromPartners": 4, "recurring": 31 },
"revenueCents": 2734500,
"collectedCents": 2280400,
"platformFeeCents": 22804,
"onTime": { "measured": 181, "onTime": 168, "pct": 92.8 },
"utilizationPct": 71.3,
"reviews": { "count": 64, "average": 4.84 },
"texts": { "sent": 655, "failed": 3 },
"trips": { "count": 1, "costCents": 10160 },
"byTech": [
{ "id": 1, "name": "Maria Gomez", "jobs": 41, "bookedMinutes": 3690, "utilizationPct": 76.9, "onTimePct": 95.1 }
],
"byService": [
{ "id": 1, "name": "Seasonal tune-up", "jobs": 140, "revenueCents": 1806000, "defaultMinutes": 60, "learnedMeanMinutes": 52 }
]
}| Field | Notes |
|---|---|
jobs.booked |
Jobs starting in the range that are booked (not cancelled) |
jobs.done |
Of those, marked done in the field app |
jobs.cancelled |
Jobs starting in the range that were cancelled |
jobs.waiting |
Out-of-area requests waiting now (not limited to the range) |
jobs.sentToPartners |
Jobs you sent through CrewConnect starting in the range |
jobs.fromPartners |
Booked jobs partners sent you |
jobs.recurring |
Booked jobs from recurring work |
revenueCents |
List price of booked jobs |
collectedCents |
Customer payments (deposits and invoices) marked paid in the range |
platformFeeCents |
CrewLink's 1% on those payments |
onTime |
measured: visits where the tech tapped "arrived". onTime: arrived no more than 15 minutes after the start. pct: one decimal, null when nothing was measured |
utilizationPct |
Booked visit minutes over working hours (time off counts as working hours not booked). null with no working hours |
reviews |
Reviews left in the range; average to 2 decimals, null with none |
texts |
Customer texts in the range |
trips |
Trips starting in the range and their recorded cost |
byTech |
Every active person |
byService[].learnedMeanMinutes |
The average time on site CrewLink has learned for that service, rounded. null until there's history |
Errors: 422 invalid_request (bad dates, to before from, or more than 92 days).
#GET /v1/office/market
Weather ahead and fuel prices where you work, with what they mean for the schedule. Weather is the US National Weather Service forecast at your first branch (US only). Fuel is the US EIA weekly retail gasoline and diesel price for your region. Results are cached for 30 minutes.
Response 200:
{
"weather": [
{ "name": "Tonight", "start": "2026-10-12T18:00:00-05:00", "temperatureF": 18, "daytime": false, "forecast": "Light Snow" },
{ "name": "Tuesday", "start": "2026-10-13T06:00:00-05:00", "temperatureF": 31, "daytime": true, "forecast": "Cloudy" }
],
"fuel": { "gasolineCents": 319, "dieselCents": 461, "week": "2026-10-06", "region": "R20" },
"signals": [
"Lows of 18°F ahead (Tonight): expect more no-heat calls. Keep the on-call reserve on.",
"Diesel is $4.61 a gallon in your region: trips and long drives cost more; check trip pricing."
]
}| Field | Notes |
|---|---|
weather |
Forecast periods, or null outside the US, when the forecast is unavailable, or with no branch |
fuel |
Prices per gallon in cents, or null when unavailable. region is the EIA petroleum region code |
signals |
Plain-language advice: heat waves and cold snaps (more emergency calls), storms (longer drives), high diesel. May be empty |
#Where jobs stand and the heat map
Owners and dispatchers. Both take from and to (local dates, inclusive, up to 366 days) and, optionally, clientCompanyId or client (a CrewLink ID) to look at the work one company sent you.
#GET /v1/office/reports/status
Where the jobs filed (created) in the range stand now, the request funnel, and cycle time.
curl -s "$CREWLINK/v1/office/reports/status?from=2026-10-05&to=2026-10-11" -H "Authorization: Bearer $KEY"Response 200:
{
"from": "2026-10-05",
"to": "2026-10-11",
"tiles": { "created": 5, "unscheduled": 1, "scheduled": 0, "overdue": 0, "needsAttention": 0, "completed": 3, "completedPct": 60, "cancelled": 1, "cancelledPct": 20 },
"funnel": [
{ "key": "filed", "label": "Filed", "count": 5 },
{ "key": "scheduled", "label": "Scheduled", "count": 3 },
{ "key": "visited", "label": "Visited", "count": 3 },
{ "key": "completed", "label": "Completed", "count": 3 }
],
"cycle": { "n": 3, "medianHours": 4, "p90Hours": 10 }
}| Field | Notes |
|---|---|
tiles |
The tabs of GET /v1/office/intake for these jobs. completedPct and cancelledPct are shares of created (null when nothing was filed) |
funnel |
Of the filed jobs: put on the schedule and still there or done, reached by the tech (arrived), marked done |
cycle |
Jobs marked done in the range: hours from going on the schedule (booked, or placed from the queue) to the done tap. Median and 90th percentile (nearest rank); null with none |
#GET /v1/office/reports/heatmap
Where the work is: jobs with a visit in the range (and unscheduled jobs filed in it), not cancelled, counted per area of about 5.5 km (a twentieth of a degree each way), with the active techs whose home is in each area. Areas are given by their centre, never by address.
Response 200:
{
"from": "2026-07-14",
"to": "2026-10-11",
"jobs": 412,
"techs": 8,
"cells": [ { "key": "839:-1754", "lat": 41.975, "lng": -87.675, "jobs": 61, "techs": 2 } ],
"branches": [ { "id": "9b2e...", "name": "North", "lat": 41.95, "lng": -87.68, "areaKm": 25 } ],
"byState": [ { "state": "IL", "jobs": 398 }, { "state": "WI", "jobs": 14 } ],
"byBranch": [ { "branch": "North", "jobs": 240 }, { "branch": "West", "jobs": 172 } ]
}cells are busiest first. byState reads the state from each address (Unknown when it names none); byBranch counts each job at its nearest branch.
#KPIs, exports and the weekly report
Every number on the Reports page, as data, files and a weekly email. All of it is for the company behind the token, owners and dispatchers (O+D). Downloads use the same Authorization: Bearer header as every other call; there is no token in the URL.
Every call here takes a date range:
| Name | Type | Required | Notes |
|---|---|---|---|
from |
string | yes | YYYY-MM-DD, your company's local date |
to |
string | yes | YYYY-MM-DD, inclusive, on or after from. At most 366 days in all, so a whole year fits |
Errors for all of them: 422 invalid_request (bad dates, to before from, or more than 366 days).
Days are your company's local days. A job counts on the day it starts, a visit on its own day, a payment on the day it was paid, a text on the day it went out, a review on the day it was left.
#GET /v1/office/kpis/catalog
What each KPI means, without numbers. Use it to label a dashboard.
curl -s "$CREWLINK/v1/office/kpis/catalog" -H "Authorization: Bearer $KEY"Response 200:
{
"kpis": [
{ "key": "jobs_booked", "name": "Jobs booked", "definition": "Jobs on the schedule (not cancelled) that start in the period.", "unit": "count", "direction": "higher_is_better" },
{ "key": "on_time", "name": "On-time arrival", "definition": "Arrivals tapped in the field app no more than 15 minutes after the visit's start, as a share of arrivals tapped.", "unit": "pct", "direction": "higher_is_better" }
]
}| Field | Notes |
|---|---|
key |
Stable id; use it, not the name, in code |
unit |
count; pct (0 to 100, one decimal); cents (minor units of your currency); minutes; hours; rating (1 to 5); ratio |
direction |
higher_is_better or lower_is_better: which way is good news |
#GET /v1/office/kpis
Every KPI for the range, compared with the period just before it (same number of days), with a value per day. Add client (a client company id, or none) to count only that client's jobs, like the old dashboard's scheduling-company filter: payments, reviews and texts count only when they belong to those jobs, and trips, CrewConnect sends and payouts drop out.
curl -s "$CREWLINK/v1/office/kpis?from=2026-10-05&to=2026-10-11" -H "Authorization: Bearer $KEY"Response 200 (shortened):
{
"company": { "id": "3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a7c8e90", "name": "Northline Heating & Air", "timezone": "America/Chicago" },
"from": "2026-10-05",
"to": "2026-10-11",
"prior": { "from": "2026-09-28", "to": "2026-10-04" },
"days": ["2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10", "2026-10-11"],
"currency": "USD",
"kpis": [
{ "key": "jobs_booked", "name": "Jobs booked", "definition": "…", "unit": "count", "direction": "higher_is_better", "value": 48, "prior": 41, "delta": 7, "series": [9, 11, 8, 10, 10, 0, 0] },
{ "key": "on_time", "name": "On-time arrival", "definition": "…", "unit": "pct", "direction": "higher_is_better", "value": 93.5, "prior": 91.2, "delta": 2.3, "series": [100, 90.9, 87.5, 100, 90, null, null] }
],
"breakdowns": {
"tech": [{ "id": "1", "name": "Maria Gomez", "jobs": 14, "revenueCents": 165600, "bookedMinutes": 1260, "onTimePct": 92.9, "utilizationPct": 46.7 }],
"service": [{ "id": "1", "name": "Seasonal tune-up", "jobs": 30, "revenueCents": 387000, "bookedMinutes": 2250, "onTimePct": 93.3, "utilizationPct": null }],
"branch": [{ "id": "8a1e0c52-6f0b-4b8e-9d1a-2c3b4d5e6f70", "name": "North", "jobs": 27, "revenueCents": 301200, "bookedMinutes": 2475, "onTimePct": 92.6, "utilizationPct": 45.8 }]
},
"market": { "weather": [], "fuel": null, "signals": [] },
"generatedAt": "2026-10-12T15:30:00.000Z"
}| Field | Notes |
|---|---|
prior |
The same number of days immediately before from |
days |
Every local date in the range; each KPI's series lines up with it |
kpis[].value |
For the range. null when there is nothing to measure (no arrivals tapped, no reviews, no completed jobs); 0 means measured and zero |
kpis[].prior |
The same KPI for prior. null when prior starts before your Count KPIs from date (not a whole period to compare with), so delta is null too |
kpis[].delta |
value - prior in the KPI's unit (percentage points for pct). null when either is null |
kpis[].series |
One value per day, same rules as value |
breakdowns |
For the range only. jobs and revenueCents (list price) count each job once per tech, service or branch on it. utilizationPct is booked time over working hours; null for services |
market |
As GET /v1/office/market, at your first branch. null with no branch |
The KPIs, in the order returned:
| Key | Unit | Better | What it is |
|---|---|---|---|
jobs_booked |
count | higher | Jobs on the schedule (not cancelled) starting in the period |
jobs_completed |
count | higher | Of those, marked done in the field app |
cancellation_rate |
pct | lower | Cancelled jobs over booked plus cancelled, by start |
on_time |
pct | higher | Arrivals tapped within 15 minutes of the start (a multi-day job's first day), over arrivals tapped |
utilization |
pct | higher | "Hours booked": booked visit time over active techs' working hours. Time off still counts as working hours |
avg_drive_minutes |
minutes | lower | Estimated drive into each tech-visit, from the tech's previous stop that day or from home |
revenue |
cents | higher | List price of the service on every booked job |
collected |
cents | higher | Customer payments (deposits and invoices) marked paid |
avg_ticket |
cents | higher | Revenue per completed job, as in job P&L: the invoice before tax, else list price plus parts and travel fee |
parts_margin |
pct | higher | Parts price less cost, over price, for parts used on jobs in the period |
labor_cost_pct |
pct | lower | Job P&L labor over revenue, completed jobs |
tech_pay_per_job |
cents | lower | Technician pay: job pay plus mileage, per job completed (by the day it was done and the days driven) |
mileage_per_tech_day |
cents | lower | Mileage pay per technician-day worked (each tech's route that day, home to home) |
profit_margin |
pct | higher | Job P&L profit over revenue, completed jobs |
repeat_rate |
pct | higher | Completed jobs whose customer has another booked job within 30 days after |
review_avg |
rating | higher | Average stars of reviews left in the period |
review_count |
count | higher | Reviews left in the period |
lead_time_hours |
hours | lower | From a job being created to its start |
request_response_hours |
hours | lower | From an out-of-area request to its booking, for requests booked in the period |
texts_sent |
count | higher | Customer texts sent |
texts_failed |
count | lower | Customer texts that failed after three tries |
connect_sent |
count | higher | Jobs you sent to CrewConnect partners, by start |
connect_received |
count | higher | Jobs partners sent you, by start |
payouts_owed |
cents | lower | What you owe partners for CrewConnect jobs starting in the period |
payouts_incoming |
cents | higher | What partners owe you for CrewConnect jobs starting in the period |
emergencies |
count | higher | Same-day emergencies: pinned jobs (flexible: false) created the same day they start. Every emergency is one; a pinned office job booked for today counts too |
trips_booked |
count | higher | Trips starting in the period |
trip_cost |
cents | lower | Their hotel, per diem and mileage |
duration_ratio |
ratio | lower | Time on site as tapped (arrived to done) over the service's default length, completed jobs. Under 1 means jobs run shorter than planned |
New KPIs may be added; read them by key and skip keys you don't know.
#CSV exports
GET /v1/office/exports/{file}.csv?from=&to= with file one of jobs, invoices, payments, kpis, techs, services, plan-vs-actual. The pay report has its own, pay.csv.
curl -s -OJ "$CREWLINK/v1/office/exports/jobs.csv?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"Response 200, Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="northline-jobs-2026-10-01-to-2026-10-31.csv". The file:
- follows RFC 4180: commas between fields, CRLF line ends, a field in double quotes when it holds a comma, a quote or a line break, quotes inside doubled;
- starts with a UTF-8 byte order mark, so Excel reads accents and symbols;
- has company-local dates and times (
2026-10-12and2026-10-12 14:30) and money as decimal dollars (129.00,-12.50); - puts an apostrophe before text that a spreadsheet would run as a formula (starting with
=,+,-or@), so a customer named=HYPERLINK(...)stays text; - streams, so large ranges start downloading at once.
| File | One row per | Columns |
|---|---|---|
jobs.csv |
Job starting in the range (booked, cancelled or passed on to a partner) | job_id, date, start, status, stage, service, customer, phone, address, techs, list_price, trip_fee, currency, pinned, from_partner, arrived_at, done_at, minutes_on_site, created_at |
invoices.csv |
Invoice created in the range | invoice_number, invoice_id, job_id, status, created_at, sent_at, paid_at, items, subtotal, tax, total, deposit_applied, balance, currency |
payments.csv |
Payment dated in the range (paid date, else created) | payment_id, date, kind, direction, counterparty, status, job_id, invoice_id, amount, platform_fee, currency, provider, provider_ref, created_at, paid_at, due_at. kind is deposit, invoice or payout; direction is in or out; counterparty is customer or the partner's name |
kpis.csv |
KPI | key, name, unit, direction, value, prior, change, from, to, prior_from, prior_to, definition. unit says dollars where the API says cents, and those values are in dollars |
techs.csv |
Tech | tech_id, name, jobs, booked_hours, hours_booked_pct, on_time_pct, revenue, from, to |
services.csv |
Service with jobs in the range | service_id, name, jobs, booked_hours, on_time_pct, revenue, from, to |
plan-vs-actual.csv |
Stop (one tech, one job, one day) in the range | date, tech_id, tech, job_id, service, customer, in_final_plan, planned_arrive, planned_start, planned_finish, actual_arrive, actual_done, late_min, status, planned_onsite_min, actual_onsite_min, depart, from, from_cell, to_cell, day_type, drive_predicted_min, drive_provider_min, provider, drive_actual_min, checkin, replanned, customers_moved. See Plan vs actual for what each means; from is home, base or job; from_cell and to_cell name the ~5 km areas the drive ran between; day_type is weekday, saturday or sunday |
#GET /v1/office/reports.pdf
The KPI report as a PDF, the same one the weekly email attaches: a CrewLink header with your company and the period, a tile per KPI with the change on the prior period, jobs and revenue per day as bar charts, tables by tech, service and branch, market signals, and when it was made on every page.
curl -s -OJ "$CREWLINK/v1/office/reports.pdf?from=2026-10-05&to=2026-10-11" -H "Authorization: Bearer $KEY"Response 200, Content-Type: application/pdf, Content-Disposition: attachment; filename="northline-report-2026-10-05-to-2026-10-11.pdf".
#The weekly report email
Set weeklyReportTo with PATCH /v1/office/settings (owner only). Every Monday from 7:00 AM your time, that address gets last week's report (Monday to Sunday) as a PDF attachment, with the headline numbers in the email itself. Each week is sent once. null turns it off.
#Plan vs actual
CrewLink keeps each day's plan and lines it up with what happened in the field. It's what the Reports → Plan vs actual page shows, as data. Owners and dispatchers (O+D), your own company only.
How the plan is recorded. A day's plan is recorded at its cutoff, 6:00 PM your time the evening before, and again every time it changes from then on: a new booking for that day, a move, a cancellation, an emergency, a job running long. Each recording is a snapshot: every tech's stops in order, with when the tech should set off, the drive, when they should arrive, and the visit's start and finish. Changes to days further ahead aren't recorded until their cutoff.
What is measured. When a tech taps on the way, CrewLink notes the drive the plan expected at that moment and, if your budget allows, asks Mapbox (live traffic) how long that same drive should take right now. Arrived gives the real drive time and how early or late the tech was against the planned start; done gives the real time on site; a mid-job check-in records whether the job ran long and how many later customers had to move.
The review. Every night from 3:00 AM your time, yesterday is worked out once and stored, and a day.reviewed webhook goes out. You can also review a day now.
#The numbers
The same fields appear in a day's metrics, in each reviewed day of the summary and in the summary's totals. Minutes have one decimal; null means nothing to measure yet.
| Field | What it is |
|---|---|
onTimeRate |
Percent of arrivals tapped no more than 15 minutes after the planned start (the same rule as the on_time KPI) |
lateMinMean |
Average minutes after the planned start at arrival; negative is early |
etaMaeOurs |
CrewLink's arrival error: the average gap, in minutes, between the arrival the plan predicted (the plan in force when the tech set off) and the real arrival |
etaMaeProvider |
Mapbox's arrival error: the average gap between departure plus Mapbox's live-traffic drive time (asked as the tech set off) and the real arrival |
paired |
{ n, ours, provider }: both errors on the same drives only, the fair head-to-head. Use this one to compare |
driveErrMean, driveMae |
Real drive minutes minus planned, on average (positive: drives took longer), and the same without the sign |
providerDriveErrMean |
Real drive minutes minus Mapbox's |
onsiteErrMean, onsiteMae |
Real minutes on site minus planned (positive: jobs ran long), and without the sign |
byTech, byHour, byService |
The same numbers split by tech (key = person id), by the hour of the planned start (key = 0 to 23, name like 09:00) and by service. Each has key, name, stops, arrivals, onTimeRate, lateMinMean, etaMaeOurs, etaMaeProvider, driveErrMean, onsiteErrMean |
counts |
plannedStops (in the day's final plan), arrivals, done, drives (on-the-way taps), drivesMeasured (with arrival too), providerLookups, paired, checkins, replans (changes after the cutoff), customersMoved (because a job ran long), snapshots |
#GET /v1/office/brain/day
One day: every tech's stops planned against worked, and the day's numbers worked out now from what's recorded.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
date |
query | string | no | YYYY-MM-DD, local. Default today |
curl -s "$CREWLINK/v1/office/brain/day?date=2026-10-12" -H "Authorization: Bearer $KEY"Response 200 (shortened):
{
"date": "2026-10-12",
"timezone": "America/Chicago",
"provider": "mapbox",
"reviewedAt": "2026-10-13T08:10:00.000Z",
"metrics": { "onTimeRate": 100, "lateMinMean": -1.5, "etaMaeOurs": 6.1, "etaMaeProvider": 10.5, "paired": { "n": 2, "ours": 6.1, "provider": 10.5 }, "driveErrMean": 9.4, "onsiteErrMean": -5, "byTech": [], "byHour": [], "byService": [], "counts": { "plannedStops": 2, "arrivals": 2, "done": 1, "drives": 2, "drivesMeasured": 2, "providerLookups": 2, "paired": 2, "checkins": 1, "replans": 3, "customersMoved": 0, "snapshots": 4 } },
"snapshots": [
{ "id": 812, "takenAt": "2026-10-11T23:00:04.000Z", "reason": "cutoff", "engineVersion": "@crewlink/engine@0.1.0", "travelModelVersion": "ffi-straight-1.3x-45kmh-prior-v2", "stops": 2 }
],
"techs": [
{
"personId": 1,
"name": "Maria Gomez",
"stops": [
{
"jobId": 1040, "service": "Seasonal tune-up", "customer": "Jamie Cruz", "address": "1600 W Belmont Ave, Chicago", "inFinalPlan": true,
"plannedArrive": "2026-10-12T11:47", "plannedStart": "2026-10-12T12:00", "plannedFinish": "2026-10-12T13:15",
"actualArrive": "2026-10-12T11:52", "actualDone": "2026-10-12T13:02", "lateMin": -8, "status": "ok",
"plannedOnsiteMin": 75, "actualOnsiteMin": 70, "checkin": true, "replanned": true, "customersMoved": 0,
"drive": { "from": "home", "fromJobId": null, "departAt": "2026-10-12T11:30", "predictedMin": 16.8, "providerMin": 18, "provider": "mapbox", "actualMin": 22, "ourEtaErrorMin": 5.2, "providerEtaErrorMin": 4 }
}
]
}
]
}| Field | Notes |
|---|---|
provider |
The traffic service compared against (mapbox), or null when none is set up for your account |
reviewedAt |
When the stored review was made, or null. metrics is always worked out now |
snapshots[] |
Every recording of the day's plan, oldest first. reason: cutoff (the first, normally 6 PM the evening before), replan (the plan changed), manual (recorded on request). engineVersion and travelModelVersion say what produced it |
techs[].stops[] |
Each stop of the day's final plan, plus any stop the tech worked that later moved off their day (inFinalPlan: false). Times are local wall clock |
plannedArrive |
When the plan expected the tech to arrive (it aims 5 minutes before the start) |
lateMin, status |
Minutes after the planned start at arrival (negative: early), and the Dispatch colour: ok 5 or more minutes early, tight up to 10 minutes late, late more than that. null until the tech taps arrived |
checkin, replanned, customersMoved |
A mid-job check-in happened; it said the job would run long, so the rest of the day was replanned; later customers handed to a teammate, moved or asked to pick a new time because of it |
drive |
null without an on-the-way tap. from: home, base (a trip's base) or job (fromJobId). predictedMin is the plan's, providerMin Mapbox's at the moment of departure (null when it wasn't looked up), actualMin on the way to arrived. ourEtaErrorMin and providerEtaErrorMin are real arrival minus each prediction (positive: later than predicted) |
Errors: 401, 403 (a tech token).
#GET /v1/office/brain/days
The most recent days with a stored review that had something planned or worked, newest first. Plan vs actual opens on the first one, so it never lands on an empty weekend or a morning before anything has happened. Today is included once it has been reviewed.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
limit |
query | integer | no | 1 to 60, default 1 |
curl -s "$CREWLINK/v1/office/brain/days?limit=3" -H "Authorization: Bearer $KEY"Response 200:
{ "days": [
{ "date": "2026-10-09", "reviewedAt": "2026-10-10T08:05:00.000Z", "plannedStops": 31, "arrivals": 30, "onTimeRate": 93.3 },
{ "date": "2026-10-08", "reviewedAt": "2026-10-09T08:05:00.000Z", "plannedStops": 28, "arrivals": 28, "onTimeRate": 89.3 }
] }#GET /v1/office/brain/summary
The reviewed days in a range as a series (one point per reviewed day), and the whole range's numbers worked out together.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
from, to |
query | string | yes | YYYY-MM-DD, at most 366 days |
curl -s "$CREWLINK/v1/office/brain/summary?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"Response 200 (shortened):
{
"from": "2026-10-01",
"to": "2026-10-31",
"days": [
{ "date": "2026-10-12", "reviewedAt": "2026-10-13T08:10:00.000Z", "onTimeRate": 100, "etaMaeOurs": 6.1, "etaMaeProvider": 10.5, "paired": { "n": 2, "ours": 6.1, "provider": 10.5 }, "driveErrMean": 9.4, "onsiteErrMean": -5, "counts": { "plannedStops": 2, "arrivals": 2, "done": 1, "drives": 2, "drivesMeasured": 2, "providerLookups": 2, "paired": 2, "checkins": 1, "replans": 3, "customersMoved": 0, "snapshots": 4 } }
],
"totals": { "onTimeRate": 100, "etaMaeOurs": 6.1, "byTech": [], "byHour": [], "byService": [], "counts": { "plannedStops": 2 } }
}totals has every field of the numbers. Errors: 401, 403, 422 invalid_request (bad dates, to before from, over 366 days).
#POST /v1/office/brain/review
Review a day now: store its numbers and send day.reviewed. Any day up to today; today's review covers what has happened so far, and the night run reviews it again once the day is over.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
date |
query | string | yes | YYYY-MM-DD, local |
curl -s -X POST "$CREWLINK/v1/office/brain/review?date=2026-10-12" -H "Authorization: Bearer $KEY"Response 200: { "date": "2026-10-12", "reviewedAt": "2026-10-12T21:00:00.000Z", "metrics": { … } }.
Errors: 401, 403, 422 not_yet (a day that hasn't started).
#POST /v1/office/brain/snapshot
Record a day's plan as it stands now (reason: manual), for example before a big reshuffle. Plans are recorded on their own at the cutoff and after every change, so you rarely need this.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
date |
query | string | no | YYYY-MM-DD, today or later. Default today |
Response 201: { "id": 815, "date": "2026-10-13", "takenAt": "2026-10-13T08:10:00.000Z", "reason": "manual", "stops": 1 }.
Errors: 401, 403, 422 in_the_past.
#GET /v1/office/brain/settings and PATCH /v1/office/brain/settings
Each time a tech sets off, CrewLink can ask Mapbox how long that drive should take in live traffic, to compare with its own prediction. Your company has a daily budget of these lookups (default 50); a platform-wide daily limit applies on top, so on a very busy day some drives may not be looked up. A tap that reaches CrewLink more than 10 minutes late (queued offline) isn't looked up, since live traffic then is no longer the traffic of that drive. Drives that aren't looked up still count for everything else.
curl -s -X PATCH "$CREWLINK/v1/office/brain/settings" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -d '{ "travelLookupsPerDay": 100 }'Response 200 (both):
{ "travelLookupsPerDay": 100, "lookupsToday": 12, "provider": "mapbox" }| Field | Notes |
|---|---|
travelLookupsPerDay |
0 to 1000; 0 turns lookups off. PATCH is owner only |
lookupsToday |
Lookups spent on today's drives |
provider |
mapbox, or null when no traffic service is set up (then nothing is looked up) |
Errors: 401, 403 (PATCH by a dispatcher, or a tech token), 422 invalid_request.
#Technician pay
What each tech earned, week by week. CrewLink works it out; it doesn't move the money (pay techs through your payroll or bank and record it here). The rules in full: docs/dev/pay.md.
- Job pay. Each completed job pays its service's
basePayCents(or the person's own rate,PUT /v1/office/team/{id}/pay-rates/{serviceId}). A job two or more people work is split equally between them (the first people by id get the odd cent), unless the service'spaySplitiseach. - Mileage. Each day a tech works is paid over the whole route: home, every job finished that day in order, home (a trip day starts or ends at the trip base). Road distance when CrewLink's road router covers the area, else straight-line distance × 1.3. At
mileageRateCentsper mile (default 25), or per km when your company works in metric. - Adjustments. Bonuses and deductions the office adds, each with a reason.
- Weeks. Pay lands in the week the job was finished (the week starts on
weekStartsOn, Monday by default). A week isopenwhile it builds up; once it has ended an owner closes it (closed: the totals are frozen, mileage is redone in road distance first) and marks itpaidwith a reference. A job finished after its week was closed counts in the next open week. - CrewConnect. A job a partner sent you pays your tech on your rates; what the partner pays you (payouts) doesn't change.
Pay accrues when the job is marked done: the tech's done tap in the field app, or POST /v1/office/jobs/{id}/complete (silent or not). A day the person works from a base (POST /v1/office/team/{id}/bases) starts and ends there for mileage, like a trip base.
#GET /v1/office/pay
Query: from, to (local dates; default last pay week's start to today), or period (one week, by its start date); personId; designation (w2 or 1099).
curl -s "$CREWLINK/v1/office/pay?period=2026-10-12" -H "Authorization: Bearer $KEY"Response 200:
{
"unit": "mi",
"periods": [{ "start": "2026-10-12", "end": "2026-10-18", "status": "closed", "closedAt": "2026-10-19T14:02:00.000Z", "paidAt": null, "reference": null }],
"rows": [{
"periodStart": "2026-10-12", "periodEnd": "2026-10-18", "status": "closed",
"personId": 1, "name": "Maria Gomez", "designation": "1099",
"jobs": 9, "jobPayCents": 81000, "distance": 212.4, "unit": "mi", "mileageCents": 5310, "adjustmentsCents": 2500, "totalCents": 88810,
"paidCents": 0, "dueCents": 88810, "currency": "USD", "paidAt": null, "reference": null
}],
"totals": { "jobs": 9, "jobPayCents": 81000, "distance": 212.4, "mileageCents": 5310, "adjustmentsCents": 2500, "totalCents": 88810, "paidCents": 0, "dueCents": 88810 }
}One row per week and tech with anything that week. Open weeks are live; closed and paid weeks show the totals frozen at close.
#GET /v1/office/pay/lines
Query: period (the week's start), or jobId (every line for that job, in any week: what cancelling a done job would take back, and the reversal afterwards), and personId. One of period or jobId is required (422 otherwise). Response 200: { "lines": [...] }, by date. Each line: id, personId, periodStart, date, kind (job, mileage or adjustment), jobId, service, customer, amountCents, currency, distance and unit (mileage), basis (mileage: road, straight or mixed), rateCents (the job's base pay before the split, or the rate per mile), reason (adjustments; for a shared job, e.g. 1 of 2 shares).
#POST /v1/office/pay/adjustments
Body: personId, amountCents (positive for a bonus, negative for a deduction, not 0), reason (1 to 300), date (optional, default today), currency (optional, default USD).
Response 201: the line. Errors: 404 not_found (not on your team), 409 period_closed (that week is closed or paid; reopen it or date the adjustment in an open week).
#DELETE /v1/office/pay/adjustments/{id}
Response 200: { "deleted": true }. 409 period_closed once its week is closed.
#POST /v1/office/pay/periods/{start}/close
Owner only. Response 200: { "period": <period>, "people": 6 }. Errors: 404 not_found (no week starts then), 409 not_over (the week hasn't ended), 409 not_open. A week with nothing in it is marked paid straight away.
#POST /v1/office/pay/periods/{start}/reopen
Owner only, for a closed week nobody in it has been marked paid for. Response 200: the period, open again. 409 not_closed / partly_paid otherwise.
#POST /v1/office/pay/periods/{start}/paid
Owner only. Body: reference (1 to 200: a check number, payroll run or bank batch), personIds (optional: only these people; default everyone not yet paid).
Response 200: { "marked": [1, 4], "status": "paid", "unpaid": 0 }. When nobody is left unpaid the week is paid. 409 not_closed for an open week.
#GET /v1/office/pay/settings and PATCH /v1/office/pay/settings
{ "mileageRateCents": 25, "unit": "mi", "weekStartsOn": 0, "techsEditHours": true, "techsSeePay": true }| Field | Notes |
|---|---|
mileageRateCents |
Cents per route mile (per km in metric), 0 to 10000. Default 25 |
unit |
mi, or km when the company works in metric. Read only |
weekStartsOn |
0 = Monday (default) ... 6 = Sunday. Weeks already started keep their dates |
techsEditHours |
Techs may change their own hours and home address in the field app, from the next day. Default true |
techsSeePay |
Techs see a "My pay" card in the field app (GET /v1/tech/pay). Default true |
PATCH is owner only; send any of the four. Response 200: every pay setting as it is now.
#GET /v1/office/pay/rates
Response 200: { "services": [{ "id", "name", "basePayCents", "paySplit", "currency", "active" }], "overrides": [{ "personId", "serviceId", "basePayCents" }] }.
#PUT /v1/office/team/{id}/pay-rates/{serviceId}
Owner only. Body: { "basePayCents": 15000 }. Used instead of the service's base pay for jobs this person completes from now on. Response 200: { "personId", "serviceId", "basePayCents" }. DELETE the same path removes it (404 when there was none).
#GET /v1/office/exports/pay.csv
The pay report with the same query as GET /v1/office/pay, as CSV (format): period_start, period_end, period_status, tech_id, name, designation, jobs, job_pay, distance_mi (or distance_km), mileage, adjustments, total, paid, due, currency, paid_at, reference.
Pay also shows in the KPIs: tech_pay_per_job (job pay plus mileage per completed job) and mileage_per_tech_day (catalog).
#Wins
Streaks first, then personal bests, badges, weekly team goals and an opt-in leaderboard. It's what the Reports → Wins page and the Team page show, as data. Owners and dispatchers (O+D) read; only an owner changes the settings. Your own company only.
Nothing is invented. Every number comes from what was recorded: the field app's arrived and done taps, mid-job check-ins, the plan vs actual ledger, reviews and CrewConnect jobs sent. On time is the on_time KPI rule (arrived no more than 15 minutes after the visit's start), so Wins and Reports always agree. A job worked by a crew counts for everyone on it: one tap credits the whole crew, and so does the review. Numbers are recomputed on every arrived and done tap and every review, and for everyone each night from 4:00 AM your time.
Fairness. A miss starts a streak again; nothing else does. Days off, time off and days without jobs pause a streak (it waits, it never breaks), and a visit with no arrival tap neither counts nor breaks the on-time streak. A streak that ended is never shown as a loss: the apps only say a new one starts with the next stop. A tech's first 30 days (from their first visit) are their rookie month. The leaderboard is off until an owner turns it on, only ever shows the top 3 (plus, for a tech, their own row), never the bottom, and any tech can hide themselves. Drive minutes against the plan are shown to the tech only; there is no badge or ranking for driving fast.
#Streaks
| Streak | type |
Counts | A miss |
|---|---|---|---|
| On-time arrivals | on_time |
Arrivals in a row no more than 15 minutes after the visit's start | A late arrival |
| On-the-way taps | on_the_way |
Working days in a row where every stop got an "on the way" tap (by anyone on the crew) before the arrival | A day with an arrival that had no tap first |
| Five-star reviews | five_star |
Five-star reviews in a row on jobs the tech worked | A review under five stars |
| Inside the planned time | no_checkin |
Jobs in a row finished no more than 5 minutes past the planned finish (so no check-in was needed) | A job that ran longer |
| Company on-time days | (company) | Days in a row the company's on-time rate was at or above the on-time goal (90% without one). Today counts once it's at or above it, and can't end the streak until the day is over | A finished day under the target |
Each has current, best and startedOn (the local day the current one began). At risk means the next thing that could end it is close: on time, a stop starting within 30 minutes (or already started) not arrived at yet; on the way, a stop within the hour not yet tapped; inside the planned time, a job on site within 15 minutes of its planned finish; the company, today's rate so far under the target. Milestones are 5, 10, 25, 50 and 100, then every 100: each one reached in a run is a feed entry and a celebration (the on-time streak's are its Clockwork tiers).
#Badges
Most badges have three tiers; Clockwork, which hangs off the on-time streak, has five (bronze, silver, gold, platinum, diamond). Once earned, a tier is kept. A new tier adds a feed entry, a badge.earned webhook and a celebration in the tech's app. (Tiers reached from history before Wins existed are recorded quietly: no feed entry, webhook or celebration.)
| Badge | key |
Counts | Bronze | Silver | Gold |
|---|---|---|---|---|---|
| Clockwork | clockwork |
Best on-time streak (arrivals in a row); platinum at 50, diamond at 100 | 5 | 10 | 25 |
| Five-star | five_star |
Five-star reviews on jobs you worked | 5 | 25 | 100 |
| Early bird | early_bird |
Arrivals 5 or more minutes before the start | 10 | 50 | 150 |
| Closer | closer |
Jobs finished | 25 | 100 | 500 |
| One and done | one_and_done |
Jobs finished with no return job for the same customer and service starting within 14 days (recurring work and jobs without a customer don't count) | 10 | 50 | 200 |
| Heads-up | heads_up |
Jobs that ran more than 5 minutes past the planned finish where someone on the crew checked in with the extra time (+15, +30 or +60) before the planned finish | 3 | 10 | 25 |
#The wins feed
System-written entries, newest first. kind:
kind |
When | Example title |
|---|---|---|
badge |
A tech reaches a badge tier | Maria Gomez hit a 25-visit on-time streak |
streak |
A tech's streak reaches a milestone (once per run; the on-time ones that are also a new Clockwork tier come as the badge) | Maria Gomez finished 25 jobs in a row inside the planned time |
team_streak |
The company's on-time days reach a milestone | 10 days in a row at or above the on-time goal (90%) |
best_day |
A tech finishes more jobs in one day than ever before (3 or more; one entry per day) | Maria Gomez set a personal best: 6 jobs in a day |
team_best_week |
Last week's on-time rate beat every week of the year before it (at least 10 arrivals, and at least 4 earlier weeks with 10) | Best week of the year for on-time: 96% of 48 arrivals |
goal_met |
Last week met a goal | The team hit its on-time arrival goal: 94% (goal 90%) |
Team entries about a finished week are written the night after it ends, so they show in the week after. Feed entries never contain customer names or addresses.
#GET /v1/office/wins
A week's goals against your targets, the wins feed and each active tech's streak and badges.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
week |
query | string | no | Any day of the week, YYYY-MM-DD (weeks run Monday to Sunday, your time). Default this week |
curl -s "$CREWLINK/v1/office/wins?week=2026-10-12" -H "Authorization: Bearer $KEY"Response 200 (shortened):
{
"week": { "from": "2026-10-12", "to": "2026-10-18" },
"settings": { "leaderboard": true, "goals": { "onTimeRate": 90, "firstTimeFixRate": null, "reviewsPerWeek": 5, "connectSentPerWeek": null } },
"companyStreak": { "current": 6, "best": 14, "startedOn": "2026-10-05", "target": 90, "targetIsGoal": true, "atRisk": false, "nextMilestone": 10, "today": { "arrivals": 12, "onTimeRate": 91.7 } },
"longestStreaks": [{ "personId": 1, "name": "Maria Gomez", "current": 25, "startedOn": "2026-09-22" }],
"goals": [
{ "key": "on_time", "name": "On-time arrival", "unit": "pct", "target": 90, "actual": 92.5, "prior": 88, "progress": 100, "met": true, "measuredOn": 40 },
{ "key": "first_time_fix", "name": "First-time fix", "unit": "pct", "target": null, "actual": 95, "prior": 100, "progress": null, "met": null, "measuredOn": 20 },
{ "key": "reviews", "name": "Reviews", "unit": "count", "target": 5, "actual": 3, "prior": 6, "progress": 60, "met": false, "measuredOn": 3 },
{ "key": "connect_sent", "name": "CrewConnect jobs sent", "unit": "count", "target": null, "actual": 2, "prior": 0, "progress": null, "met": null, "measuredOn": 2 }
],
"feed": [
{ "id": 77, "kind": "badge", "at": "2026-10-12T16:52:00.000Z", "personId": 1, "person": "Maria Gomez", "title": "Maria Gomez hit a 25-visit on-time streak", "badge": { "key": "clockwork", "name": "Clockwork", "tier": "gold" }, "streak": null }
],
"techs": [
{ "personId": 1, "name": "Maria Gomez", "rookie": null, "streak": { "current": 25, "best": 25, "startedOn": "2026-09-22", "atRisk": false, "paused": false }, "badges": [{ "key": "clockwork", "name": "Clockwork", "tier": "silver" }], "week": { "arrivals": 8, "onTime": 8, "onTimeRate": 100, "done": 8, "fiveStar": 2 } }
],
"badges": [{ "key": "clockwork", "name": "Clockwork", "description": "On-time arrivals in a row …", "thresholds": { "bronze": 5, "silver": 10, "gold": 25, "platinum": 50, "diamond": 100 } }]
}| Field | Notes |
|---|---|
companyStreak |
The company's on-time days in a row (see Streaks): target is the on-time goal, else 90 (targetIsGoal false); today is today so far; atRisk while today is under the target |
longestStreaks[] |
The three longest on-time streaks running now |
goals[] |
One per goal, set or not. actual this week so far (null: nothing to measure yet), prior the week before, progress actual as a percent of the target (at most 100), met whether actual is at or above the target (null without a target). measuredOn: arrivals for on time, finished jobs for first-time fix, the count itself for the others |
feed[] |
Entries written during the week. person is the tech's full name (null for team entries); badge is set for kind: badge, streak ({ type, length }, type team_on_time for the company) for streak and team_streak |
techs[] |
Active techs. streak is the on-time streak with startedOn and atRisk (the Team page's dot: green kept, yellow at risk, grey paused or not running); streak.paused is true when today is a day off or time off for them. badges is the highest tier per badge earned. rookie is { "daysLeft": n } during the first 30 days |
Errors: 401, 403 (a tech token), 422 invalid_week.
#PATCH /v1/office/wins/settings
Owner only. Send only what changes; a goal set to null is removed.
curl -s -X PATCH "$CREWLINK/v1/office/wins/settings" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -d '{ "leaderboard": true, "goals": { "onTimeRate": 90, "reviewsPerWeek": 5 } }'| Field | Type | Notes |
|---|---|---|
leaderboard |
boolean | Show techs the company leaderboard. Default false |
goals.onTimeRate |
number or null | Percent, 0 to 100 |
goals.firstTimeFixRate |
number or null | Percent, 0 to 100 |
goals.reviewsPerWeek |
integer or null | 0 to 10000 |
goals.connectSentPerWeek |
integer or null | 0 to 10000 |
Response 200: { "leaderboard": true, "goals": { "onTimeRate": 90, "firstTimeFixRate": null, "reviewsPerWeek": 5, "connectSentPerWeek": null } }.
Errors: 401, 403 (a dispatcher or tech), 422 invalid_request.
#GET /v1/office/wins/leaderboard
The week's leaderboard, top 3 only. Two boards: on_time (on-time rate, techs with at least 5 arrivals in the week; ties go to more arrivals) and five_star (five-star reviews in the week, at least one). Techs who hid themselves aren't ranked. enabled: false with no boards until an owner turns it on.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
week |
query | string | no | Any day of the week. Default this week |
Response 200:
{
"enabled": true,
"week": { "from": "2026-10-12", "to": "2026-10-18" },
"boards": [
{ "key": "on_time", "name": "On-time arrivals", "unit": "pct", "top": [
{ "rank": 1, "personId": 1, "name": "Maria Gomez", "value": 100, "detail": "8 of 8 arrivals", "rookie": false },
{ "rank": 2, "personId": 4, "name": "Diego Ramirez", "value": 90, "detail": "9 of 10 arrivals", "rookie": true }
], "you": null },
{ "key": "five_star", "name": "Five-star reviews", "unit": "count", "top": [], "you": null }
]
}Errors: 401, 403, 422 invalid_week.
#Billing, invoices, payouts and reviews
Customer payments go through CrewLink's payment provider to your connected account, at one published all-in rate: card 2.9% + 30¢, ACH 1% capped at $15 a payment. CrewLink's 1% platform fee is inside that rate, not on top of it. CrewConnect payouts to partners: standard payouts are free, an instant payout is 1%. Deposits are taken at booking (booking-page.md) and come off the invoice. Customers view and pay invoices, and leave reviews, through texted links (booking-page.md).
#Your CrewLink plan
CrewLink is priced by jobs: a monthly (or yearly) price with jobs included, then a small fixed price per job over. The full rules, with worked examples, are in docs/dev/pricing.md.
| Plan | code |
Monthly | Yearly (2 months free) | Included jobs | Each job over | Who it's for |
|---|---|---|---|---|---|---|
| Connect | connect |
$0 | – | 0 | – | 1099 contractors who only take CrewConnect work |
| Independent | independent |
$39 | $390 | 75 | $0.75 | 1099 contractor, owner + 1 helper login, no multi-crew board |
| Crew | crew |
$149 | $1,490 | 300 | $0.65 | 2–4 trucks (crew trades up to ~8) |
| Shop | shop |
$349 | $3,490 | 800 | $0.55 | 5–12 trucks (crew trades up to ~25) |
| Fleet | fleet |
$899 | $8,990 | 2,500 | $0.45 | 13–40 trucks |
| Enterprise | enterprise |
$1,999 | $19,990 | 6,000 | $0.38 | 40–200+ trucks. Book a call; custom above 15,000 jobs a month |
- What counts. A job counts once, in the month it was booked, on the plan of the company that originated it. A CrewConnect job counts for the sender, never for you when you receive it; a job passed on again still counts once, for the original sender. Cancelled jobs aren't counted. A moved job is the same job. A multi-day install (one job, several visits) is one job. Holds and out-of-area requests that were never booked don't count.
- Best-price guarantee. A month's subscription + overage never costs more than the next plan's monthly price. Enterprise has no cap.
- First overage month waived, once per account.
- Free trial: 30 days, nothing to pay. Self-serve sign-up puts a card on file at the start (sign-up). Billing months then run from the day the trial ends.
- Yearly plans cost 10 months and pool 12 months of jobs across the year (Crew: 3,600). Overage only starts once the year's pool is used, and is billed in the month it happens.
- Scheduling is never blocked: not by the plan, the overage, the trial or a failed payment (that only shows
status: past_due). - Independent and Connect are for individual (1099) accounts. They include two logins (you and one helper): adding a third (
POST /v1/office/users), turning one back on past two (/reactivate), or inviting anyone but the first two active logins (/invite, e.g. after moving down to Independent) gets422 plan_limit. A Connect seat that books its own jobs is billed as Independent for that month.
#GET /v1/office/billing
Your plan and its bill for this month, itemized. ?period=YYYY-MM-DD shows the month containing that date instead (a past month is frozen once it ends).
What counts as a job: one booked through CrewLink in the period (by when it was booked), whatever its number of visits or techs. Not counted: holds, cancelled jobs (credited), work received through CrewConnect (the sender pays), imports (source: "import": work moved from another system), test work (for a test client, or with a test person on it) and, in a demo account, the jobs the demo generator wrote (a job someone books there by hand counts).
curl -s "$CREWLINK/v1/office/billing" -H "Authorization: Bearer $KEY"Response 200 (a 4-truck HVAC shop on Crew, 450 jobs, in a month after its first overage month):
{
"plan": { "code": "crew", "name": "Crew", "monthlyCents": 14900, "annualCents": 149000, "includedJobs": 300, "overageCents": 65, "perJobCents": 49.67, "capAt": 608, "breakeven": 608, "next": "shop", "selfServe": true, "for": "any", "maxUsers": null, "multiCrewBoard": true, "who": "Companies, 2–4 trucks (service trades); crew trades up to ~8" },
"interval": "month",
"period": { "from": "2026-10-01", "to": "2026-11-01" },
"usage": { "jobs": 450, "included": 300, "over": 150, "overageCents": 9750, "platformFeeCents": 14700, "subscriptionCents": 14900, "totalCents": 24650 },
"statement": {
"plan": "crew", "interval": "month", "period": { "from": "2026-10-01", "to": "2026-11-01" }, "trial": false, "closed": false,
"jobs": 450, "cancelledCredited": 12, "receivedNotCounted": 31, "allowance": 300,
"lines": [
{ "kind": "base", "description": "Crew, monthly", "cents": 14900 },
{ "kind": "overage", "description": "150 jobs over the 300 included, at $0.65", "qty": 150, "unitCents": 65, "cents": 9750 }
],
"totalCents": 24650, "capped": false, "waived": false, "note": null,
"fees": {
"card": { "count": 310, "amountCents": 1470000, "feeCents": 51930, "processingFeeCents": 37230, "platformFeeCents": 14700 },
"ach": { "count": 0, "amountCents": 0, "feeCents": 0, "processingFeeCents": 0, "platformFeeCents": 0 },
"total": { "count": 310, "amountCents": 1470000, "feeCents": 51930, "processingFeeCents": 37230, "platformFeeCents": 14700 }
},
"jobsCsv": "/v1/office/exports/plan-usage.csv?period=2026-10-01"
},
"subscription": { "status": "active", "trialEndsAt": "2026-07-01T05:00:00.000Z", "inTrial": false, "hasCard": true, "pending": null, "spendAlertCents": null, "overageWaivedPeriod": "2026-09-01" },
"plans": [ { "code": "crew", "…": "…" }, { "code": "shop", "…": "…" }, { "code": "fleet", "…": "…" }, { "code": "enterprise", "…": "…" } ],
"rates": { "card": { "rateBp": 290, "fixedCents": 30 }, "ach": { "rateBp": 100, "capCents": 1500 }, "platformBp": 100, "payouts": { "standardBp": 0, "instantBp": 100 } },
"enterpriseCallUrl": "https://crewlink.ai/#walkthrough"
}| Field | Notes |
|---|---|
plan |
The plan this month is billed on. perJobCents is the price per included job; capAt the job count where the best-price cap starts; breakeven the job count where next becomes the cheaper plan |
interval |
month or year |
period |
The month shown, [from, to) as local dates. During the trial it's the trial window |
statement.lines[] |
kind: base (the plan's price; on a yearly plan the year's price in its first month, 0 after), overage (qty jobs × unitCents), cap_credit (best-price guarantee), waived_credit (the first overage month), adjustment (an upgrade's proration), trial_credit (the free trial). Credits are negative |
statement.jobs |
Jobs counted. cancelledCredited: booked in the month and cancelled (not counted). receivedNotCounted: CrewConnect jobs you received (they count for the sender) |
statement.closed |
The month ended and its bill is frozen |
statement.note |
E.g. a Connect seat that booked its own jobs, billed as Independent |
statement.fees |
Customer payments through CrewLink in the month (by paid date), by method: feeCents is the all-in fee you paid, processingFeeCents the payment provider's part, platformFeeCents CrewLink's 1% |
statement.jobsCsv |
The job list behind the bill (see plan-usage.csv) |
usage |
The same month in the older shape (kept for existing integrations). totalCents is the statement total |
subscription.status |
trialing, active, past_due (a payment failed; scheduling carries on), canceled or incomplete (signed up, card not confirmed yet) |
subscription.pending |
A downgrade or interval change waiting for the next period: { plan, interval, from } |
plans |
The plans this account can be on |
#GET /v1/office/billing/usage
The live meter, as the Dispatch board shows it: "612 of 800 jobs · 12 days left · on pace for 973".
Response 200:
{
"plan": "shop", "interval": "month", "status": "active", "period": { "from": "2026-10-01", "to": "2026-11-01" },
"included": 800, "used": 612, "overage": 0, "daysLeft": 12, "onPaceFor": 973,
"capAt": 1800, "nextTier": "fleet", "breakeven": 1800, "nudge": null, "nudgeTo": null, "contactSales": false,
"trial": null, "billSoFarCents": 34900, "projectedCents": 34900, "spendAlertCents": null,
"text": "612 of 800 jobs · 12 days left · on pace for 973",
"alerts": [
{ "kind": "75", "message": "You've used 612 of 800 jobs included in Shop (75%), with 12 days left. …", "channel": "email", "at": "2026-10-20T17:00:00.000Z" },
{ "kind": "forecast", "message": "On pace for 973 jobs, 22% over the 800 included in Shop. …", "channel": "email", "at": "2026-10-20T17:00:00.000Z" }
]
}| Field | Notes |
|---|---|
included, used, overage |
Jobs included, counted and over. On a yearly plan these are the year's pool and the year so far, and period is the plan year |
onPaceFor |
A straight-line forecast for the period from the days gone by, never fewer than 7 days of it (the whole period when shorter), so a busy first day doesn't read as thousands of jobs. Never less than used |
capAt |
Jobs a month where the best-price cap starts. null on Enterprise and on yearly plans |
nudge, nudgeTo |
up: 2 of the last 3 months passed the breakeven, or this month's forecast passes 85% of it (Independent → Crew at 222, Crew → Shop at 608, Shop → Fleet at 1,800, Fleet → Enterprise at 4,944). down: 3 months under half the allowance, and this month on pace for under half too. Otherwise null |
contactSales |
On pace for more than 15,000 jobs a month |
trial |
{ endsAt, daysLeft } during the free trial, else null |
projectedCents |
This month's bill at the current pace (after the cap, the waiver and the trial) |
alerts |
Alerts already sent this period, oldest first. Each kind fires once per period: 75, 90 and 100 (percent of the included jobs used), forecast (on pace for more than 110%), spend (the projected bill passes your spend alert). They're emailed to the owners when CrewLink has their email (channel: email), and always shown here and in the app (in_app when there was no one to email) |
#PATCH /v1/office/plan
Choose your plan, billing interval and spend alert. Owner only.
Body (all optional):
| Name | Type | Notes |
|---|---|---|
plan |
string | A plan code |
interval |
string | month or year |
spendAlertCents |
integer or null | Tell me when this month's projected bill passes this. null turns it off |
- Upgrades (a higher plan) apply now. You pay the price difference for the days left in the period (simple daily proration): the month is billed at the new price, with an
adjustmentline taking off the days before the upgrade at the difference. With a card on file the Stripe subscription moves now too. - Downgrades and interval changes apply from the next period (
effective: next_period); until thenGET /v1/office/billingshows them assubscription.pending. - During the free trial every change applies at once and nothing is prorated.
curl -s -X PATCH "$CREWLINK/v1/office/plan" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"plan":"shop"}'Response 200 (Crew to Shop on Oct 20, 12 of 31 days left: $200 × 12 / 31):
{ "plan": "shop", "interval": "month", "effective": "now", "effectiveFrom": "2026-10-20", "prorationCents": 7742, "spendAlertCents": null }Errors: 403 forbidden (not an owner), 422 invalid_request (no such plan for your kind of account; Independent and Connect are for individual accounts), 422 contact_sales with url (Enterprise: book a call), 502 billing_unavailable (Stripe didn't answer; nothing changed).
#PUT /v1/office/billing/plan
The older form of PATCH /v1/office/plan: body { "plan": "<code>" }, same rules, keeps your interval. Response 200 { "plan": "shop" }. Plan codes from before October 2026 (starter, team, business) are refused with 422; existing subscriptions on them are billed as Crew, Crew and Shop.
#GET /v1/office/exports/plan-usage.csv
The job list behind a month's bill: every job counted in the month containing ?period=YYYY-MM-DD (default this month), in booking order. RFC 4180 CSV with a UTF-8 byte order mark, as an attachment.
Columns: job_id, booked_at, status, service, start, done_by, source, counted. done_by is you or the partner's name (for work you sent through CrewConnect); source is own or crewconnect; counted is included or over (the jobs past the allowance, in booking order).
#POST /v1/office/billing/portal
Owner only. A Stripe Customer Portal link for the card on file, invoices and cancelling: 200 { "url": "https://billing.stripe.com/…" }. 409 no_card when there's no card yet (use checkout below), 502 billing_unavailable.
#POST /v1/office/billing/checkout
Owner only. Put a card on file and start a paid plan: body { "plan": "<code>", "interval": "month" | "year" }, response 200 { "url", "sessionId" }, a Stripe Checkout link. Whatever is left of the 30-day trial carries over. Stripe sends you back to the web app (/?page=money&tab=plan&checkout=<sessionId>), which calls POST /v1/office/billing/checkout/complete { "sessionId" } → 200 { "plan", "interval", "status" } (409 checkout_incomplete while Stripe is still confirming the card; 404 for a session that isn't yours). The Stripe webhook applies it too. Enterprise: 422 contact_sales.
#The invoice object
| Field | Type | Notes |
|---|---|---|
id |
integer | |
number |
integer | Your company's invoice number, starting at 1001 |
jobId |
integer | |
status |
string | draft, sent, paid or void |
lines |
array | { "description": string, "qty": number, "unitCents": integer } |
subtotalCents |
integer | Sum of qty × unitCents, rounded |
taxCents |
integer | |
totalCents |
integer | subtotalCents + taxCents |
depositAppliedCents |
integer | Paid deposit taken off |
balanceCents |
integer | What the customer still owes. Always 0 for a void invoice |
currency |
string | The service's currency, e.g. USD |
In GET /v1/office/invoices and GET /v1/office/jobs/{id}/invoice each invoice also has customer (the job's customer name, or null) and service (the job's service name).
A job has one invoice at a time: drafting another while it has a draft, sent or paid one is refused with 409 invoice_exists. Void the old one first (a paid one can't be voided).
#POST /v1/office/jobs/{id}/invoice
Draft an invoice for a job. Without lines, the invoice has the service at its list price, every part the tech logged (at price), and the travel fee if there is one. Any deposit already paid comes off the balance. An invoice with nothing left to pay is created as paid. Only one at a time per job: see GET /v1/office/jobs/{id}/invoice for the current one.
Path: id (integer) job id (not cancelled).
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
lines |
array | no | 1 to 100 lines, replacing the defaults. Each: description 1 to 200 characters, qty > 0 and up to 10,000 (decimals allowed, e.g. 1.5 hours), unitCents integer >= 0 |
taxRateBp |
integer | no | Tax in basis points, 0 to 5000 (825 = 8.25%). Default 0. Applies to the whole subtotal |
curl -s -X POST "$CREWLINK/v1/office/jobs/1544/invoice" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"taxRateBp":1000}'Response 201:
{
"id": 88,
"number": 1001,
"jobId": 1544,
"status": "draft",
"lines": [{ "description": "Repair visit", "qty": 1, "unitCents": 8900 }],
"subtotalCents": 8900,
"taxCents": 890,
"totalCents": 9790,
"depositAppliedCents": 4900,
"balanceCents": 4890,
"currency": "USD"
}Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No such job, or it's cancelled |
| 409 | invoice_exists |
The job already has a draft, sent or paid invoice. The body has its invoiceId; void it to draft a new one |
| 422 | invalid_request |
A field failed validation |
{ "error": "invoice_exists", "message": "This job already has an invoice. Void it to draft a new one.", "invoiceId": 88 }#GET /v1/office/jobs/{id}/invoice
The job's current invoice: its draft, sent or paid one (void ones don't count).
Path: id (integer) job id.
Response 200: the invoice with customer and service:
{
"id": 88,
"number": 1001,
"jobId": 1544,
"status": "sent",
"lines": [{ "description": "Repair visit", "qty": 1, "unitCents": 8900 }],
"subtotalCents": 8900,
"taxCents": 890,
"totalCents": 9790,
"depositAppliedCents": 4900,
"balanceCents": 4890,
"currency": "USD",
"customer": "Jamie Cruz",
"service": "Repair visit"
}Errors: 404 not_found (no job with that id in your company, or it has no invoice other than void ones).
#POST /v1/office/invoices/{id}/send
Send an invoice: CrewLink texts the customer a link to view it and, when your company takes payments online (a connected Stripe account), starts a card payment for the balance that the link offers. Without a connected account the invoice is still sent (payOnline: false): the link shows the balance and asks the customer to pay you directly. Sending a sent invoice again issues a new link; the old one stops working. When the customer pays online, the invoice becomes paid and the invoice.paid webhook event is sent.
Path: id (integer) invoice id. No body.
Response 200: the invoice object with "status": "sent" and the customer's link:
{
"id": 88,
"number": 1001,
"jobId": 1544,
"status": "sent",
"lines": [{ "description": "Repair visit", "qty": 1, "unitCents": 8900 }],
"subtotalCents": 8900,
"taxCents": 890,
"totalCents": 9790,
"depositAppliedCents": 4900,
"balanceCents": 4890,
"currency": "USD",
"link": "https://go.crewlink.ai/i/88?t=Qm9vZ2llV29vZ2llMTIz",
"payOnline": true,
"texted": true
}payOnline: the link offers a card payment. false when no payment account is connected yet; tell the customer how to pay you.
texted: the text with the link was queued for the customer. false when the job sends the homeowner nothing (its notify: false, your company's homeownerMessaging: "off", or the booking platform's grant) or its phone number can't be texted; the invoice is still sent, so share link with them yourself.
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No invoice with that id |
| 409 | not_sendable |
The invoice is paid or void |
| 502 | payments_unavailable |
Your company takes payments online but the payment provider couldn't be reached. Nothing changed; try again in a few minutes |
#GET /v1/office/invoices
Invoices, newest first (up to 500).
Query:
| Name | Type | Required | Notes |
|---|---|---|---|
status |
string | no | draft, sent, paid or void |
Response 200: { "invoices": [ <invoice>, ... ] }, each with customer (the job's customer name, or null) and service (the job's service name).
Errors: 422 invalid_request (unknown status).
#POST /v1/office/invoices/{id}/void
Void a draft or sent invoice, for example one sent with the wrong lines. Its balanceCents becomes 0 (totalCents and depositAppliedCents stay as they were), the customer's link then shows it as void with nothing to pay (balanceCents 0, payment null), and its pending payment is dropped. To bill the job again, draft a new invoice. A paid invoice can't be voided (refund it instead).
Path: id (integer) invoice id. No body.
Response 200: the invoice, with "status": "void". Voiding an invoice that is already void returns it unchanged.
Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No invoice with that id in your company |
| 409 | not_voidable |
The invoice is paid |
#GET /v1/office/payouts
CrewConnect money: what you owe partners for work they did for you, and what partners owe you. A payout is created when a CrewConnect job is marked done, on the connection's agreed pay terms (crewconnect.md). When you passed a job on to your own partner, you get both: incoming from the company that sent it to you (on your terms with them, for their job id) and owed to your partner (on your terms with them, for the job they did).
Response 200:
{
"payouts": [
{
"id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"direction": "owed",
"counterparty": "Other Co",
"jobId": 1588,
"amountCents": 9030,
"currency": "USD",
"status": "scheduled",
"dueAt": "2026-10-27T19:15:00.000Z",
"instantFeeCents": 90
}
]
}| Field | Notes |
|---|---|
direction |
owed (you pay the partner) or incoming (the partner pays you) |
counterparty |
The other company's name |
amountCents |
From the terms: a percent of the job's list price, or a flat amount |
status |
scheduled until settled |
dueAt |
When the job was done plus the terms' net days |
instantFeeCents |
What an instant payout of it would cost (1%). Standard payouts are free |
#GET /v1/office/reviews
Customer reviews, newest first (up to 500). Customers get a review link by text 30 minutes after a job is marked done.
Response 200:
{
"average": 4.84,
"count": 64,
"reviews": [
{ "jobId": 1188, "rating": 5, "comment": "Maria was on time and explained everything.", "customer": "Jamie Cruz", "at": "2026-10-12T18:02:11.000Z" }
]
}average is over the reviews returned, to 2 decimals, null with none. Each new review sends the review.created webhook event.
#Webhook endpoints
Manage the URLs CrewLink sends events to. Events, payloads, signature checks and retries are in webhooks.md.
The endpoint object:
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | |
url |
string | |
events |
array of strings | Event names, or ["*"] for all |
active |
boolean | false once a platform's grant that made it is revoked |
platform |
string or null | The slug of the platform that subscribed it for your company, or null for your own. A platform key lists and removes only its own; your keys see all of them |
A platform with the webhooks scope can also keep one platform-wide subscription for all the companies that granted it (platforms.md); it isn't listed here, and revoking the grant stops it getting your events.
Delivery log. GET /v1/office/webhooks/{id}/deliveries (owner only; a platform key: only endpoints it subscribed) lists what was queued for one endpoint, newest first, with attempts, lastAttemptAt, nextAttemptAt, lastStatusCode, lastError and the payload as sent; filters event, jobId, status (pending, delivered, failed), limit (1 to 200, default 50) and before (cursor). POST /v1/office/webhooks/{id}/deliveries/{deliveryId}/retry queues one again now. Details and examples: webhooks.md.
#POST /v1/office/webhooks
Subscribe a URL to events. Owner only. Returns the signing secret once.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | https:// URL, up to 2000 characters. See the rules below |
events |
array of strings | yes | At least one event name from webhooks.md, or * for all |
The URL must be https on a public host name:
- no IP addresses, and no user name or password in the URL;
- no internal names (
localhost,*.localhost,*.internal, names without a dot likeintranet, ...); - the host must not resolve to a private, loopback, link-local, carrier-grade NAT, multicast or reserved address;
- port 443 or 8443, unless the host already resolves to a public address (then any port).
CrewLink checks the host again before every delivery and never follows redirects: a 3xx response counts as a failed delivery.
curl -s -X POST "$CREWLINK/v1/office/webhooks" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"url":"https://hooks.northlinehvac.com/crewlink","events":["booking.created","booking.rescheduled","booking.cancelled"]}'Response 201:
{
"id": "4d5e6f7a-8b9c-4d0e-a1f2-3b4c5d6e7f80",
"url": "https://hooks.northlinehvac.com/crewlink",
"events": ["booking.created", "booking.rescheduled", "booking.cancelled"],
"active": true,
"secret": "whsec_<signing secret>"
}Use secret to verify each delivery (webhooks.md). It isn't shown again.
Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
Not an owner |
| 422 | invalid_request |
Not a valid https:// URL, too long, no events, or an unknown event name |
| 422 | invalid_url |
The URL breaks one of the rules above. message says which |
#GET /v1/office/webhooks
Your endpoints. Secrets aren't included. Owners and dispatchers.
Response 200:
{
"endpoints": [
{ "id": "4d5e6f7a-8b9c-4d0e-a1f2-3b4c5d6e7f80", "url": "https://hooks.northlinehvac.com/crewlink", "events": ["booking.created", "booking.rescheduled", "booking.cancelled"], "active": true }
]
}#DELETE /v1/office/webhooks/{id}
Remove an endpoint. Owner only. Deliveries still waiting to be sent to it are dropped.
Path: id (UUID).
Response 200:
{ "id": "4d5e6f7a-8b9c-4d0e-a1f2-3b4c5d6e7f80", "deleted": true }Errors: 403 forbidden (not an owner), 404 not_found, 422 invalid_request (id isn't a UUID).
To rotate a secret, add a new endpoint, switch your receiver to the new secret, then delete the old one.
#Platforms
A platform is another product your company lets act for it, such as a survey platform scheduling surveys for its clients through CrewLink. It calls with its own key and your company's slug, acting as an owner within the scopes you grant, and never reaches users and tokens, keys, settings, your plan, money or CrewConnect writes. Every call it makes is logged. The platform's side is in platforms.md.
| Scope | Lets the platform |
|---|---|
read |
See jobs, the schedule, the catalog, open times and files |
write |
Book, hold, move, cancel and complete jobs, add notes, flag attention, add and remove its files (read included) |
webhooks |
Get your events (its platform-wide subscription, or endpoints it adds here) |
#GET /v1/office/platforms
Owners and dispatchers. Response 200: { "grants": [ { "platform": { "slug": "acme", "name": "Acme" }, "scopes": ["read", "webhooks", "write"], "homeownerMessaging": "on", "grantedAt": "...", "revokedAt": null, "grantedBy": "<user id or null>" } ] }, newest first, revoked ones included.
#POST /v1/office/platforms/{slug}/grant
Owner only (not a platform key). Body { "scopes": ["read", "write", "webhooks"], "homeownerMessaging": "off" } (at least one scope; homeownerMessaging optional). Grants access, or replaces the scopes of a live grant. homeownerMessaging left out keeps a live grant's option; a new grant starts on, except on a client company, which starts with the platform's option on the provider that manages it. A new grant sends platform.grant.created to the platform. Response 200: the grant. Errors: 403 forbidden, 404 not_found (no such platform), 422 invalid_request.
#PATCH /v1/office/platforms/{slug}/grant
Owner only (not a platform key). Body { "homeownerMessaging": "off" } or "on". off: every job the platform books for you sends the homeowner nothing (it's booked notify: false), and jobs it already booked stop too (the text worker drops anything queued for them). on lets them text again, except jobs booked while it was off (their own flag stays off). Response 200: the grant. Errors: 403 forbidden, 404 not_found (no live grant). See Homeowner messaging.
#Homeowner messaging
Whether a job texts or emails its homeowner (confirmation, reminder, on-the-way and ETA, running-late moves, reschedules, cancellation, review link, invoice) depends on three switches; any one off and the job sends nothing:
- the job's own
notifyflag (notify: falseonPOST /v1/office/jobs, holds and moves), - the company's
homeownerMessagingsetting (PATCH /v1/office/settings), - the
homeownerMessagingoption of the grant of the platform that booked the job (above).
#DELETE /v1/office/platforms/{slug}/grant
Owner only. Revokes it at once: the platform's key gets 404 for your company, the endpoints it subscribed here are turned off, its platform-wide subscription stops getting your events and gets platform.grant.revoked. Jobs it booked stay. Response 200: { "platform": "acme", "revoked": true }. Errors: 403 forbidden, 404 not_found (no live grant).
#Company, settings and the API key
#GET /v1/office/me
Who the token belongs to and which company, with the company's current wall-clock time. A good first call to check a token.
Response 200:
{
"company": {
"id": "3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a7c8e90",
"name": "Northline Heating & Air",
"slug": "northline",
"timezone": "America/Chicago",
"country": "US",
"units": "mi",
"kind": "company",
"cancelCutoffHours": 24,
"onCall": { "start": 720, "end": 1020 },
"weeklyReportTo": "owner@northline.example",
"allowedOrigins": ["https://www.northline.example"],
"integrationKey": true,
"homeownerMessaging": "on",
"defaultTechRadiusMiles": null,
"bufferMinutes": 10,
"metricsFrom": null,
"productAnalytics": "on",
"availabilityManagedBy": null,
"availabilityManagedByName": null,
"locationRetentionDays": 90
},
"role": "owner",
"scope": "company",
"teamLeadId": null,
"userId": null,
"now": "2026-10-12T10:30",
"superAdmin": false,
"actingAs": null
}| Field | Notes |
|---|---|
company.slug |
Your booking page address (booking-page.md) |
company.timezone |
IANA zone. Every local time in the API is in it |
company.kind |
company, individual for an independent contractor, client for a client company, or platform for CrewLink's own internal company (CrewLink staff only, admin.md) |
company.onCall |
The on-call window, or null when off |
company.weeklyReportTo |
Where the weekly report goes, or null when off |
company.allowedOrigins |
Websites whose pages may call the booking API from the browser (Calling from a browser) |
company.integrationKey |
true when a website integration key exists. The key itself is shown only when it is made |
company.homeownerMessaging |
off: no job texts or emails its homeowner (Homeowner messaging) |
company.defaultTechRadiusMiles |
How far from home (miles) people without a branch are offered work when they have no maxTravelMiles; null: 40 km. Platform keys can read it here |
company.bufferMinutes |
Buffer between jobs: minutes kept clear before every visit, after the drive (default 10). See Drive time and the buffer |
company.metricsFrom |
Count KPIs from: a local date (YYYY-MM-DD). No KPI or metric counts anything before it (Count KPIs from); null: no limit |
company.productAnalytics |
on (default) or off: whether CrewLink's own product analytics records anything about the company (PATCH /v1/office/settings) |
company.availabilityManagedBy, company.availabilityManagedByName |
The integration that owns your technicians' hours, time off, home and bases (e.g. acme, "Acme"), or null: managed here (Availability managed by an integration) |
company.locationRetentionDays |
How long techs' location trails are kept, days (7 to 365, default 90; PATCH /v1/office/settings, platforms.md) |
company.cancelCutoffHours |
How close to the start the homeowner (manage link) and client companies may still cancel or move; the office and platforms on your grant have no cutoff |
company.timezone |
The default zone (branches may have their own) |
company.country |
ISO 3166-1 alpha-2 of the head office |
company.units |
mi or km: show every distance this way (the API itself is metric) |
role |
owner, field_manager or dispatcher |
scope |
company, team or own (Roles and scopes) |
teamLeadId |
Dispatchers on a team: their field manager's user id |
userId |
null for the company API key, and while CrewLink staff act as your owner |
now |
Company wall clock |
superAdmin |
true only for CrewLink staff signed in as themselves (admin.md); always false for your own people |
actingAs |
Set while a CrewLink super admin is acting as your owner (support, with your say-so): { adminUserId, adminName, expiresAt }. null otherwise. Every request made that way is logged with their name (admin.md) |
#PATCH /v1/office/settings
Company rules. Owner only. Send any of the fields; the response has every setting as it is now (the same values as company in GET /v1/office/me), so a client can refresh its settings screen from it.
Body:
| Name | Type | Required | Notes |
|---|---|---|---|
onCall |
object or null | no | { "start": integer, "end": integer }, minutes after local midnight (each branch's own clock), each 0 to 1440, end after start. On weekdays in this window, CrewLink keeps one tech per branch free for same-day emergencies: those times aren't offered on the booking page. null turns it off |
timezone |
string | no | The company's default IANA zone (America/Chicago, Europe/London). Branches with their own zone keep it. Every booked instant is kept: jobs of people who follow the company's zone are re-expressed on the new clock, and jobs booked before zones per branch keep the old one. People without a branch keep their home's zone |
country |
string | no | ISO 3166-1 alpha-2 of the head office (US, GB, DE). With every branch's country it decides which numbers get texts, how a number without +country is read, where address search looks and the default units |
units |
string or null | no | mi or km: how every distance is shown to people (service areas, trip and mileage costs, the booking page). null: the country's usual units (miles in the US and UK, km elsewhere) |
cancelCutoffHours |
integer | no | 0 to 168, default 24. How close to the start customers can still cancel or move a booking online (their manage link), and client companies can still take back a scheduled request (POST /v1/connect/requests/{id}/cancel, 409 too_late). The office and platforms on your grant have no cutoff |
homeownerMessaging |
string | no | off: no texts or emails to homeowners for any of your jobs, and anything queued is dropped; on (default). See Homeowner messaging |
defaultTechRadiusMiles |
number or null | no | 1 to 3000: how far from home (miles, straight line) people without a branch are offered work when they have no maxTravelMiles of their own; null: 40 km. For a national company whose techs work from home |
bufferMinutes |
integer | no | 0 to 60, default 10. Buffer between jobs: minutes kept clear before every visit, after the drive (parking, getting to the door, slack), between every two visits and before the first one of the day. Applies to every new booking and move; nothing booked is re-timed. See Drive time and the buffer |
metricsFrom |
string or null | no | Count KPIs from: a real local date, YYYY-MM-DD (422 otherwise). KPIs, reports, plan vs actual and Wins count nothing before it; null (the default) counts everything. See Count KPIs from |
productAnalytics |
string | no | off: CrewLink's own product analytics (PostHog) records nothing about your company, from the server or from your people's browsers and phones; on (default) |
locationRetentionDays |
integer | no | 7 to 365, default 90. How long techs' location trails (on-the-clock fixes from the field app or a platform's tech app, for mileage and pay disputes) are kept; older fixes are deleted by the hourly retention job, so shortening it deletes them within the hour. Read a trail with GET /v1/office/team/{id}/locations |
shareLinkHost |
string or null | no | Where your clients' share links are served: your own domain, such as book.example.com, once CrewLink serves it for you (422 until then). null: your platform's, else go.crewlink.ai. See Your own domain |
shareLinkMailFrom |
string or null | no | The From address of share-link emails (its domain verified with the mail provider). null: your platform's, else the server's |
weeklyReportTo |
string or null | no | An email address, up to 254 characters. Every Monday from 7:00 AM company time it gets last week's KPI report PDF. null turns it off |
allowedOrigins |
array of strings | no | Up to 20 websites, each an https origin: https://host or https://host:port, no path (https://www.northline.example). Pages on these sites may call your public booking endpoints straight from the browser (Calling from a browser). The list replaces the old one; [] allows none. Duplicates are dropped and hosts are lower-cased |
curl -s -X PATCH "$CREWLINK/v1/office/settings" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"onCall":{"start":720,"end":1020},"cancelCutoffHours":24,"weeklyReportTo":"owner@northline.example","allowedOrigins":["https://www.northline.example"]}'Response 200:
{ "onCall": { "start": 720, "end": 1020 }, "cancelCutoffHours": 24, "weeklyReportTo": "owner@northline.example", "allowedOrigins": ["https://www.northline.example"], "timezone": "America/Chicago", "country": "US", "units": "mi", "integrationKey": false, "homeownerMessaging": "on", "defaultTechRadiusMiles": null, "bufferMinutes": 10, "metricsFrom": null, "productAnalytics": "on", "locationRetentionDays": 90, "shareLinkHost": null, "shareLinkMailFrom": null }#Count KPIs from
metricsFrom (owner only, Settings > Weekly report > "Count KPIs from") is a local date in your company's time zone. Use it after importing old jobs, so your numbers start fresh. Every KPI and metric read starts no earlier than that day:
- KPIs (range, prior period, daily series and breakdowns), and everything built on them: the KPI exports, the PDF and the weekly report email.
GET /v1/office/reports,/reports/status(by filing date) and/reports/heatmap.- Plan vs actual over a range (
GET /v1/office/brain/summary). - Wins: the week's goals, team wins, every streak (a streak can't count anything before the date), the company's on-time days, leaderboards, each tech's stats and badge progress (
GET /v1/office/wins,GET /v1/tech/me/wins). Badges and wins feed entries from before the change are hidden, never deleted; badges the new window earns again are re-dated when they are. Rookie framing still counts from a tech's first job ever.
A range entirely before the date is all zeros (rates null). A KPI's prior (and so its delta) is null while the prior period starts before the date: the first weeks after it have nothing whole to compare with, instead of every count reading "+N vs 0". Those responses carry metricsFrom, so a screen can say "Counting from Mon Oct 12, 2026". null puts everything back as it was.
Not affected (these are money and records, not KPIs): technician pay and the pay report, invoices and payments, billing and the plan meter, job lists and history, the dispatch board (one day, live), the plan vs actual view of a single day, and every CSV other than the KPI ones (jobs, invoices, payments, plan-vs-actual stops).
#Drive time and the buffer
A time is offered, booked or moved to only if the tech can leave the previous stop (or the day's start point, after their shift starts) when it ends, drive there, and still have bufferMinutes to spare before the start; and, after the job, reach the next stop the same way. The drive is planned on the slow side for that hour and day: road free flow (OSRM) times the larger of what CrewLink has learned for the area and time of day and a conservative city curve (about 1.4x free flow on weekday daytimes, up to 2x at rush hour), plus a 20% margin, rounded up to a whole minute. Learned traffic can make a plan slower, never tighter than that curve. Every way a job is placed uses the same check: open times and the booking page (and its holds), POST /v1/office/jobs, moves (and drag-and-drop on the board), recurring visits, intake suggestions, CrewConnect, emergencies and trips. Imports honouring an old commitment (source: import) are history and skip it.
Example (2026-10-11 QA): a job ends at 1:55 PM in Evanston; the next is at 4544 N Lincoln Ave (13 minutes of free flow, Google "typically 22 to 45"). The drive is planned at 23 minutes, so with the default buffer the first time offered is 2:30 PM and a 2:15 PM booking is 409 slot_taken (reason: doesnt_fit).
Changing bufferMinutes (or this planning) never moves booked jobs. A booked leg that no longer has room shows on the dispatch board as a tight risk until someone moves it.
integrationKey says whether a website integration key exists (the key itself is shown only when made).
Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
Not an owner |
| 422 | invalid_request |
onCall.end not after onCall.start, a value out of range, weeklyReportTo not an email address, an allowedOrigins entry that isn't an https origin (http://, a path, a wildcard), or more than 20 of them, an unknown timezone or country |
#POST /v1/office/api-key/rotate
Replace the company API key. Owner only (the current key, an owner token or an owner's session). The new key is returned once, as <company id>.<secret>; CrewLink keeps only a hash, so store it now. The old key stops working at once, so update every integration that uses it first. User tokens and sessions are not affected.
No body.
curl -s -X POST "$CREWLINK/v1/office/api-key/rotate" -H "Authorization: Bearer $KEY"Response 200:
{ "apiKey": "3f6c2a9e-8b1d-4c7a-9e2f-5d4b1a7c8e90.<secret>" }Errors: 403 forbidden (not an owner).
#POST /v1/office/integration-key/rotate
Create or replace the company's integration key, for booking screens built into your own website. Owner only. There is no key until you make one. The key is returned once, as pk_<secret>; CrewLink keeps only a hash. Making a new one stops the old one at once.
Send it as the X-CrewLink-Key header on the public booking endpoints. The holds, bookings, out-of-area requests and address-search limits then count per key instead of per visitor IP, with higher ceilings (300 a minute per endpoint, 50 live holds), and the traffic is attributed to your company. It is safe to put in your website's code: it grants no access to any data and works only on your own company's endpoints. Never put the company API key in a web page. See Calling from a browser.
No body.
curl -s -X POST "$CREWLINK/v1/office/integration-key/rotate" -H "Authorization: Bearer $KEY"Response 200:
{ "integrationKey": "pk_<integration key>" }Errors: 403 forbidden (not an owner).