CrewLink API

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

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:

bash
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:00Z or 2026-11-16T10:00:00-06:00. Office bookings and moves also take local instead: the wall clock where the job is (2026-11-16T10:00), so you don't need to know the branch's zone. Dates are YYYY-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 (and eta, expiresAt, at, ...) is the real instant in UTC, e.g. 2026-11-16T16:00:00.000Z. local is the wall clock where the job is, YYYY-MM-DDTHH:mm, e.g. 2026-11-16T10:00, with its zone in timezone. 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's units (mi or km, from GET /v1/office/me).
  • Minutes after midnight. Working hours and the on-call window are minutes after local midnight: 480 is 8:00, 1020 is 17:00, 1440 is the end of the day. Each day's hours are [start, end] with 0 <= start < end <= 1440 (anything else is 422).
  • Money is an integer in minor units (...Cents: 12900 is $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:

json
{ "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:

json
{
  "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
bash
curl -s "$CREWLINK/v1/office/schedule?date=2026-11-16" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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.

bash
curl -s "$CREWLINK/v1/office/live" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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
bash
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.hours are that day's working windows in minutes after local midnight. utilization is 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/end instants, local/localEnd wall clock), state, jobStatus, stage, overdue, the attention flag and homeownerTexts. driveMinutesBefore is 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"); null for holds. eta and minutesLate are live, while the tech is on the way to that stop. risk (level on_time / at_risk / late, eta, signed minutesLate, reason, basis) covers every stop not reached yet today and every arrived stop; enRouteAt, arrivedAt, completedAt are the field taps; plannedOrder / actualOrder compare the booked order with the order of arrival; actualDriveMinutes is the drive as it happened (platforms.md).
  • Live (only when date is the tech's today; null otherwise): status ok, tight, late, idle or on_job (on site and on track) and doing, by the live board's rules, plus fieldStatus (off, idle, en_route, on_site), currentJobId and nextJobId; location is the last position the field app sent (at, and accuracy, heading, speed when 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.unscheduled counts all of it.
  • Risks: late and tight (live), overdue stops on the day, and aging queue jobs (waiting 3 or more days), each with a one-line message.
  • 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.

bash
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)
bash
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 GeoJSON LineString ([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. null when nothing is left. progressMeters: how far along route the tech is (draw what is behind as driven).
  • legs[]: one per remaining stop: from (position, origin = where the day starts, or job), fromJobId, toJobId, distanceMeters and durationSeconds (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), signed minutesLate, provider (mapbox with live traffic, osrm, or straight) and endIndex (where in route.coordinates the leg ends).
  • stops[]: the tech's booked stops that day in order (order), with start, end, local, stage, state, reached (done, on site or arrived), eta, risk { level, minutesLate }, customer (name only), service, client, address { full, lat, lng }, externalRefs.
  • positionState says 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); null on another day.
  • mapStyle: CrewLink's map style and public map token (as on the field app's route), or null (draw without tiles). Always null for 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

json
{
  "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.

bash
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:

json
{ "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": true to the body (?force=true on the DELETEs). It is applied, recorded in the audit below, and its webhooks carry "forced": true. force from anyone else is 403 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; null follows the company. Team reads carry availabilityManagedBy (the result), availabilityManagedByName and ownAvailabilityManagedBy (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

json
{ "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.

bash
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
bash
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:

json
{ "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:

json
{ "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/to local dates, inclusive, up to 366 days; lat/lng may come from address parts. 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 the externalRefs ids 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 (tripId set); removing that one is 409 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

json
{ "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
bash
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).

bash
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

json
{
  "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.

bash
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) get 409 outside_daylight with 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. With allowQueue on POST /v1/office/jobs the request goes to the intake queue instead.
  • An owner can override it: allowOutsideHours: true on POST /v1/office/jobs or a move books a daylight-only service outside daylight (working hours and every other check still apply). Dispatchers and field managers get 403 owner_only for it. On any other service allowOutsideHours stays for imports only.
  • Exempt: imports (source: "import") and history written with allowPast.

#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)
bash
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:

json
{
  "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:

json
{ "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)
bash
curl -s "$CREWLINK/v1/office/blocks?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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
bash
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:

json
{
  "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.

bash
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:

json
{
  "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_busy with personId, conflictJobId (the job in the way) and a message; with allowQueue: true the 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_taken with that reason, 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:

json
{
  "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)
bash
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:

json
{
  "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: true with techIds: 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_taken with the reason). The same as for an import: the answer adds doubleBooked ([{ personId, conflictJobId }], one per tech) when an overlap was actually skipped, and the moved job is flagged needs attention Double-booked with #<conflictJobId> (every job it overlaps). job.needs_attention goes out as for any flag, unless the move is notify: false (or the job's homeowner texts are off): then only the flag and its timeline entry are written. booking.rescheduled (or booking.created, from the queue) goes out either way. Without techIds: 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; 422 when there are none), no availability, hours or overlap checks, visits of the service's length, overdue until 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 (or booking.created from the queue) goes out with the techs and history: true (history emissions reach only webhook endpoints with historyEvents: true). Without allowPast, a past start is still 409 in_the_past for everyone.
bash
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}'
json
{ "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 unscheduled with intakeReason: "Unassigned"; its visits are removed (it leaves the board, the techs' days and their calendars); job reads show the kept time in requested ({ start, local }), as for a bumped job. Place it again with a move and techIds (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 notify is sent; queued texts for the job (the reminder) are dropped. notify may still be sent to store the job's flag.
  • booking.rescheduled goes out with source: "office", status: "unscheduled", techs: [], start and local null, unassigned: true, from (the old local start) and requested ({ start, local }, the time it keeps). There is no separate booking.unassigned event: subscribers to booking.rescheduled with historyEvents: true see it (endpoints without it don't get unassigns).
  • Send no start or local (422); not with allowOverlap, allowPast or allowOutsideHours (422); 422 for a job that isn't booked (already in the queue) or is done.
bash
curl -s -X POST "$CREWLINK/v1/office/jobs/1042/move" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"techIds":[]}'
json
{ "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.

bash
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}'
json
{ "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_attention goes out as for any flag, unless the move is notify: 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 (with notify: false: no homeowner text; booking.rescheduled or booking.created always, with the techs).
bash
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}'
json
{ "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 }] }
bash
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]}'
json
{ "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:

json
{ "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" with cancelledAfterDone: true; doneAt is kept as history;
  • nobody is texted unless you send notify: true (a done job's default is false);
  • booking.cancelled carries wasDone: 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", lineId is that line). It shows in GET /v1/office/pay/lines and the pay report like any adjustment.
json
{
  "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_corrected entry (old and new time and techs), and updatedAt moves, so updatedSince picks it up: an integration mirroring techs re-reads the job from there.
  • The status stays as it is: cancelled stays cancelled, done stays done (doneAt is 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, end and techs; visits on 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)
bash
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:

json
{ "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:

json
{
  "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).

bash
curl -s "$BASE/v1/office/jobs/4182/texts" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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).


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: true on 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:

json
{
  "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:

json
{
  "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: allowPast on 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 attention timeline entry and job.needs_attention with source: "system", by: "closeout", also for a job whose notify is false (notify only 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:

  1. siteId sent on POST /v1/office/jobs.
  2. The same external id as another visit or a site, on a booking that isn't an import, such as your platform's acme id (one platform job = one site). Per-visit ids never group: a source ending in Visit (acmeVisit) and the old CrewLink's request ids (crewlink-v1); nor do an import's ids (source: "import").
  3. 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.
  4. 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.
  5. 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 }.

json
{
  "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.

bash
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:

json
{
  "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
bash
curl -s "$CREWLINK/v1/office/calendar?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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:

json
{
  "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
bash
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:

json
{
  "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
bash
curl -s "$CREWLINK/v1/office/credentials?expiringWithin=30" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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:

json
{ "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)
bash
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:

json
{
  "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:

json
{
  "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.

bash
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.

json
{ "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.

bash
curl -s -X POST "$CREWLINK/v1/office/team/invite-all" -H "Authorization: Bearer $KEY"

Response 200:

json
{ "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:

json
{ "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:

json
{
  "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
bash
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.

bash
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):

json
{
  "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.

json
{
  "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.

bash
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/schedule as busy time only (hidden: true) and on GET /v1/office/dispatch as busy blocks (no live status there), and only their own on GET /v1/office/requests;
  • can move, cancel, accept or decline only jobs and requests in their scope (404 otherwise, 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, POST and DELETE /v1/office/jobs/{id}/attention; another person's job is 404), 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) answers 403 scoped.
json
{ "error": "scoped", "message": "You see your own requests and jobs (or your team's). Ask an owner for this." }

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
bash
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:

json
{
  "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:

  1. 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.
  2. A CrewLink company connects to you as a client (managed: false): it calls POST /v1/connect/connections with kind: "client" (crewconnect.md) and you accept it (here with status: "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
bash
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 clientCompanyId is 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:

json
{
  "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
bash
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
bash
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:

json
{
  "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.

json
{
  "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:

json
{ "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
bash
curl -s "$CREWLINK/v1/office/trips/suggestions?from=2026-11-02&to=2026-11-13" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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
bash
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:

json
{ "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:

json
{
  "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
bash
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:

json
{
  "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:

json
{ "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:

json
{
  "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
bash
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:

json
{ "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:

json
{
  "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:

json
{ "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
bash
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:

json
{ "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.

bash
curl -s "$CREWLINK/v1/office/jobs/1620/pnl" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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
bash
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:

json
{ "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:

json
{
  "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).

bash
curl -s $CREWLINK/v1/ops/travel -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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) or straight_line (the distance-based estimate). router.regions lists 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. datasetDate is when the road data was built. resting is true for 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); hitRate is null before 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. enabled is false with a reason when 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: version goes up once a day while it is learning. (Before 2026-10-12 region keys had no country: chicago is now us-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.

bash
curl -s $CREWLINK/v1/ops/brain -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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), or null when 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. version is null when that metro has no trained settings (the built-in ones apply). on_call_start/on_call_end are 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
bash
curl -s "$CREWLINK/v1/office/reports?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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:

json
{
  "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.

bash
curl -s "$CREWLINK/v1/office/reports/status?from=2026-10-05&to=2026-10-11" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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:

json
{
  "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.

bash
curl -s "$CREWLINK/v1/office/kpis/catalog" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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.

bash
curl -s "$CREWLINK/v1/office/kpis?from=2026-10-05&to=2026-10-11" -H "Authorization: Bearer $KEY"

Response 200 (shortened):

json
{
  "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.

bash
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-12 and 2026-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.

bash
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
bash
curl -s "$CREWLINK/v1/office/brain/day?date=2026-10-12" -H "Authorization: Bearer $KEY"

Response 200 (shortened):

json
{
  "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
bash
curl -s "$CREWLINK/v1/office/brain/days?limit=3" -H "Authorization: Bearer $KEY"

Response 200:

json
{ "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
bash
curl -s "$CREWLINK/v1/office/brain/summary?from=2026-10-01&to=2026-10-31" -H "Authorization: Bearer $KEY"

Response 200 (shortened):

json
{
  "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
bash
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.

bash
curl -s -X PATCH "$CREWLINK/v1/office/brain/settings" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{ "travelLookupsPerDay": 100 }'

Response 200 (both):

json
{ "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's paySplit is each.
  • 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 mileageRateCents per 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 is open while 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 it paid with 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).

bash
curl -s "$CREWLINK/v1/office/pay?period=2026-10-12" -H "Authorization: Bearer $KEY"

Response 200:

json
{
  "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

json
{ "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
bash
curl -s "$CREWLINK/v1/office/wins?week=2026-10-12" -H "Authorization: Bearer $KEY"

Response 200 (shortened):

json
{
  "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.

bash
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:

json
{
  "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).

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) gets 422 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).

bash
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):

json
{
  "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:

json
{
  "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 adjustment line 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 then GET /v1/office/billing shows them as subscription.pending.
  • During the free trial every change applies at once and nothing is prorated.
bash
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):

json
{ "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
bash
curl -s -X POST "$CREWLINK/v1/office/jobs/1544/invoice" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"taxRateBp":1000}'

Response 201:

json
{
  "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
json
{ "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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 like intranet, ...);
  • 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.

bash
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:

json
{
  "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:

json
{
  "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:

json
{ "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:

  1. the job's own notify flag (notify: false on POST /v1/office/jobs, holds and moves),
  2. the company's homeownerMessaging setting (PATCH /v1/office/settings),
  3. the homeownerMessaging option 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:

json
{
  "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
bash
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:

json
{ "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.

bash
curl -s -X POST "$CREWLINK/v1/office/api-key/rotate" -H "Authorization: Bearer $KEY"

Response 200:

json
{ "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.

bash
curl -s -X POST "$CREWLINK/v1/office/integration-key/rotate" -H "Authorization: Bearer $KEY"

Response 200:

json
{ "integrationKey": "pk_<integration key>" }

Errors: 403 forbidden (not an owner).

CrewLink API · Reference · Changelog