CrewLink API

Field app API

The field app API is what a technician's phone talks to: the tech's day, moving a job from "on the way" to "done", turn-by-turn directions to the next stop, check-ins, location, photos, parts, notes, calling the customer, and linking a personal calendar. Use it to build your own field app or to connect a device you already use.

Everything here lives under /v1/tech/, plus the directions endpoints under /v1/field/ and one OAuth callback that Google and Microsoft call, not you.

  • Base URL: https://go.crewlink.ai (api.crewlink.ai is not set up yet). For testing, ask CrewLink for an API key for a test company.
  • Examples use $CREWLINK for the base URL and $KEY for a tech token.
  • An interactive reference for every endpoint is served at GET /docs.

#Endpoint index

Method Path What it does
GET /v1/tech/me Who the token belongs to, their company and the company's local time
GET /v1/tech/day The tech's stops for a day
GET /v1/tech/jobs/{id} One job on the tech's schedule, with its history
GET /v1/tech/jobs/{id}/attachments The job's files (proposal, photos, documents) the office or an integration added
POST /v1/tech/jobs/{id}/stage On the way, arrived, done
POST /v1/tech/jobs/{id}/checkin Mid-job check-in: on track, or how much longer
POST /v1/tech/location Report where the tech is
POST /v1/tech/jobs/{id}/notes Add a note to a job
POST /v1/tech/jobs/{id}/photos Start a photo upload: get a presigned upload URL
POST /v1/tech/jobs/{id}/photos/{photoId}/uploaded Confirm a photo finished uploading
POST /v1/tech/jobs/{id}/call Get a masked number to call or text the job's customer
GET /v1/tech/stock Parts on the tech's truck
POST /v1/tech/jobs/{id}/parts Log parts used on a job (taken off the truck)
POST /v1/tech/calendar/connect Start linking a Google or Outlook calendar
GET /v1/tech/calendar The tech's linked calendars and their sync status
POST /v1/tech/calendar/sync Sync the tech's calendars now
DELETE /v1/tech/calendar/{provider} Unlink a calendar
GET /v1/calendar/callback/{provider} OAuth callback. Called by Google or Microsoft, not by you
GET /v1/tech/pay My pay: this week's jobs, miles and total so far
GET /v1/tech/profile My hours and home address
PATCH /v1/tech/profile Change my hours or home address (from tomorrow)
GET /v1/tech/me/wins My wins: streak, this week, personal best, badges, celebrations, the leaderboard
PATCH /v1/tech/me/wins/settings Hide me from (or show me on) the company leaderboard
POST /v1/tech/me/wins/celebrated Mark celebrations as shown
GET /v1/field/next-stop My next stop today that isn't done
GET /v1/field/jobs/{id}/route Turn-by-turn driving route to one of my jobs
GET, PATCH /v1/me/tour The tech's progress through the app's first-run tour

#Who can call these

Every /v1/tech/ and /v1/field/ endpoint needs a tech token:

Authorization: Bearer <id>.<secret>

A tech token is one of:

  • A tech user token. An owner creates a user with role: "tech" linked to a person on the schedule (POST /v1/office/users with personId, see office.md). The token is shown once.
  • A tech sign-in session. The tech signs in with their email and a one-time code (see sign-in.md). Sessions last 30 days.

The token acts as the person on the schedule it is linked to. Every call is about that person: their day, their jobs, their truck, their calendar.

Office tokens (owner, dispatcher) and company API keys get 403 forbidden here, and a tech token gets 403 on office and CrewConnect endpoints. One person, one token: if two people share a phone, give each their own sign-in.

#A tech only sees their own schedule

A job is visible to a tech only while it is booked and the tech is on it (they have a visit on that job). Every job endpoint answers 404 not_found with the message "That job isn't on your schedule." for anything else: another tech's job, a cancelled job, another company's job. Jobs sent to the company through CrewConnect appear like any other job once they are on the tech's schedule.

What a tech sees about the customer is deliberately small: the first name, the address and the job notes. The customer's phone number is never returned; use the masked call instead. Teammates on the same job appear by first name.

#Times in this API

Most times here are company wall-clock time, written YYYY-MM-DDTHH:mm with no offset (for example 2026-10-12T12:00 means noon in the company's time zone). The company's IANA time zone is in GET /v1/tech/me. Fields that are instants (at in job history) are ISO 8601 in UTC.

#Offline taps and retries

A phone loses signal in basements and crawl spaces. The CrewLink app queues taps while offline and sends them when it reconnects, sometimes more than once. Two things make that safe on every POST below that changes something (stage, check-in, location, notes, parts):

at in the body: when it really happened. An ISO 8601 time with an offset, e.g. "2026-10-12T15:18:00Z". CrewLink uses it instead of the time the request arrived: for arrived and done (so time on site, and what CrewLink learns from it, is right), for the job history, for the parts log and for the position's time. It may be up to 24 hours in the past. A time a few minutes ahead of the server counts as now (phone clocks drift); anything further ahead, or older than 24 hours, is 422 invalid_at. Leave it out for a tap sent live. What the tap changes on the schedule (a check-in's extension, texts) still happens now.

Idempotency-Key header: act once. Any unique string per tap (a UUID is fine), up to 200 characters of letters, digits, _, -, . and :. The first request with a key runs and CrewLink keeps its response for 24 hours. Sending the same key again, with the same method, path and body, returns that stored response (with the header Idempotent-Replayed: true) instead of acting twice: one note, one set of parts off the truck, one stage change. Keys are per user.

Status error When
409 in_progress The first request with this key is still running. Retry shortly
422 idempotency_key_reused The key was already used for a different request (another path or body). Use a new key per tap

A response of 500 or more isn't kept, so retrying the same key runs the request again.

bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/stage \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -d '{"stage":"on_site","at":"2026-10-12T15:18:00Z"}'

#Errors

Errors always look like this:

json
{ "error": "not_found", "message": "That job isn't on your schedule." }
Status error When
401 unauthorized No token, a malformed token, or a revoked or expired one
403 forbidden The token isn't a tech token
404 not_found The job isn't on your schedule, or the photo or part doesn't exist
409 varies The request conflicts with the job's state (listed per endpoint), or in_progress (retries)
422 invalid_request The body, path or query failed validation
422 invalid_at at is more than 24 hours ago or in the future (offline taps)
422 idempotency_key_reused The Idempotency-Key was used for a different request
429 rate_limited More than 600 requests a minute from one IP address. Wait for Retry-After seconds
503 not_configured Masked calling or that calendar provider isn't available on this deployment
500 server_error Something went wrong on our side

#The tech's day

#GET /v1/tech/me

Who this token belongs to, their company, their time zone (their branch's: a Denver tech of a Chicago company works on Denver time) and the current time in it. Call it once at app start.

bash
curl -s $CREWLINK/v1/tech/me -H "Authorization: Bearer $KEY"
json
{
  "name": "Maria Gomez",
  "personId": 12,
  "timezone": "America/Chicago",
  "company": { "name": "Northline Heating & Air", "timezone": "America/Chicago", "slug": "northline" },
  "now": "2026-10-12T10:30"
}

company.slug is for address search (GET /v1/public/geocode?slug=&q=), e.g. when the tech enters a new home address.

Errors: 401, 403.

#GET /v1/tech/day

The tech's stops for one day, in start order. Only booked jobs are listed; a job stays on the list after it is done (with stage: "done").

Query Type Required Notes
date string YYYY-MM-DD no A day in the tech's time zone (their branch's). Default: today there
bash
curl -s "$CREWLINK/v1/tech/day?date=2026-10-12" -H "Authorization: Bearer $KEY"
json
{
  "date": "2026-10-12",
  "timezone": "America/Chicago",
  "stops": [
    {
      "jobId": 1042,
      "start": "2026-10-12T12:00",
      "end": "2026-10-12T13:15",
      "stage": "scheduled",
      "service": "Seasonal tune-up",
      "customer": "Jamie",
      "address": "2150 W Roscoe St, Chicago, IL",
      "lat": 41.955,
      "lng": -87.675,
      "notes": "Side gate code 4411",
      "crew": ["Maria"],
      "textsCustomer": true
    }
  ]
}
Field Notes
start, end This tech's visit, company wall-clock time. end reflects the time CrewLink expects this tech to need, which it learns from finished jobs
stage scheduled, en_route, on_site or done (see Job stages)
customer First name only, or null if the job has no customer
crew First names of everyone on the job, including this tech
textsCustomer Whether CrewLink texts this job's customer (on the way, review link). false when homeowner messages are off for the job, the company or the platform that booked it, or the customer has no phone. Say "texts the customer" only when it's true

Errors: 401, 403, 422 (bad date).

#GET /v1/tech/jobs/{id}

One job on the tech's schedule, with its history (stage changes, check-ins and notes, oldest first).

Path Type Notes
id integer The job id
json
{
  "jobId": 1042,
  "stage": "on_site",
  "service": "Seasonal tune-up",
  "address": "2150 W Roscoe St, Chicago, IL",
  "notes": "Side gate code 4411",
  "events": [
    { "kind": "en_route", "at": "2026-10-12T16:40:00.000Z", "text": null },
    { "kind": "arrived", "at": "2026-10-12T17:00:00.000Z", "text": null },
    { "kind": "checkin", "at": "2026-10-12T17:35:00.000Z", "text": "on_track" },
    { "kind": "note", "at": "2026-10-12T17:41:00.000Z", "text": "Filter was 2 sizes too small; replaced." }
  ]
}

Event kind is one of en_route, arrived, done, checkin (with the answer in text), note, attention or attention_cleared (the office flagged the job, reason in text). Events from every crew member on the job are included, and notes the office added (no tech on them).

Errors: 401, 403, 404.

#GET /v1/tech/jobs/{id}/attachments

Files on a job on the tech's schedule: the proposal, photos and documents the office or an integration added. Booked jobs only.

json
{
  "attachments": [
    { "id": "c0f7a1d2-5b3e-4f6a-8c9d-0e1f2a3b4c5d", "jobId": 1042, "kind": "proposal", "name": "Proposal - Quinn.pdf", "contentType": "application/pdf", "bytes": 482113, "status": "uploaded", "uploadedBy": { "kind": "platform", "id": "6d0c1f9a-3b2e-4c55-8f71-0a9b8c7d6e5f" }, "createdAt": "2026-11-02T18:05:00.000Z", "uploadedAt": "2026-11-02T18:05:00.000Z", "url": "https://..." }
  ]
}

url is a signed download link good for 15 minutes; ask again for a fresh one. Errors: 401, 403, 404 (not a booked job on your schedule).


#Job stages

A booked job moves through four stages. A tech moves it forward only; skipping ahead is allowed (for example straight from scheduled to on_site), going back or repeating a stage is not.

stateDiagram-v2
  direction LR
  scheduled --> en_route: on the way
  en_route --> on_site: arrived
  on_site --> done: done
  scheduled --> on_site
  scheduled --> done
  en_route --> done
Stage Meaning What CrewLink does
scheduled Booked, not started (every job starts here) Nothing. You can't set this stage
en_route The tech is on the way Texts the customer that the tech is on the way, with an ETA when you send the tech's position. Records the position. Sends the job.en_route webhook
on_site The tech has arrived Starts the on-site clock. Sends the job.arrived webhook
done The work is finished Stops the clock and records the time on site, which teaches CrewLink how long this tech takes for this service (future bookings use it). Texts the customer a review link 30 minutes later. For a CrewConnect job with agreed pay terms, records the payout the sender owes. Sends the job.completed webhook

Every tap also goes into the office's Plan vs actual: on the way, arrived and done are compared with the day's plan (the drive, the arrival against the planned start, the time on site), and a check-in records whether the job ran long. Nothing changes for the tech, and a tap never waits for it.

The customer text is sent only when the job has a customer with a US or Canadian mobile number, and only while homeowner messages are on for the job (notify), the company (homeownerMessaging) and the platform that booked it. The stop's textsCustomer says which, and the stage tap answers texted, so the app never tells the tech a text went out when it didn't. For a job that came in through CrewConnect, texts carry the brand chosen on that connection (see crewconnect.md).

What the customer receives:

Stage Text (example) When
en_route Northline Heating & Air: Maria is on the way, about 12 min out. (no ETA part without a position) Right away
on_site No text
done Northline Heating & Air: thanks for having us. How did we do? It takes 10 seconds: <review link> 30 minutes after done

Webhooks go to the company doing the job (the company the tech works for). Payloads are described in webhooks.md; the data for all three stage events is:

json
{ "jobId": 1042, "personId": 12, "at": "2026-10-12T18:12:00.000Z", "etaMinutes": 12, "minutesOnSite": null }

at is when the tech tapped (for a tap queued offline, when it happened). etaMinutes is set only on job.en_route with a position; minutesOnSite only on job.completed when the job was marked on_site first.

#POST /v1/tech/jobs/{id}/stage

Move a job to its next stage.

Body Type Required Notes
stage "en_route" | "on_site" | "done" yes Must be later than the current stage
lat number no The tech's position. Send with lng. With en_route it produces the ETA
lng number no
at string no When the tech tapped, for a tap queued offline (details). on_site and done record it as the arrival and finish time

Send an Idempotency-Key header so a retried tap moves the job once (a repeat returns the first answer instead of 409 already).

bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/stage \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"stage":"en_route","lat":41.9,"lng":-87.65}'
json
{ "jobId": 1042, "stage": "en_route", "etaMinutes": 9, "minutesOnSite": null, "texted": true }

Marking it done:

json
{ "jobId": 1042, "stage": "done", "etaMinutes": null, "minutesOnSite": 52, "texted": true }
Field Notes
etaMinutes Only for en_route with lat/lng: a rough drive estimate from the tech's position to the job, in minutes from now (at least 1). With at, the time since the tap is taken off; null when the tech would already be there by now, and then no "on the way" text is sent either (nor for a tap without a position more than 30 minutes old)
minutesOnSite Only for done, and only if the job was marked on_site first. Skipping on_site means no time is recorded and nothing is learned
texted en_route: the customer's on-the-way text was queued. done: the review link was queued (it goes out 30 minutes later). false when homeowner messages are off for this job, the customer has no textable number, or the tap is too old (above). Always false for on_site

Errors:

Status error When
404 not_found The job isn't on your schedule
409 already The job is already at that stage or past it, for example "This job is already en route."
409 not_today The job isn't today's or yesterday's work: every visit of yours on it is on a later local day ("This job is on 2026-10-15. You can update it on the day.") or more than a day ago ("This job was more than a day ago. Ask the office to close it out."). Days are the job's own time zone's. Show the message; a queued tap that gets it should be dropped and the tech told (it will never succeed). The office or an integration can still complete the job
422 invalid_request Unknown stage, or bad coordinates
422 invalid_at at out of range

#POST /v1/tech/jobs/{id}/checkin

The mid-job check-in. The CrewLink app asks it about halfway through each job: is the tech on track, or how much longer will they need? Only for a job the tech is on site at.

  • on_track just records the answer.
  • office_review asks the office to look at the job, with an optional note (the gate is locked, the customer wants a quote first). Nothing on the schedule changes; the job shows as needs attention on the office's lists until they clear it. newEnd is null and changes is [].
  • plus15, plus30 or plus60 extend the tech's visit now, while there's still time to fix the rest of the day. Every later job on the tech's day that no longer fits is handled right away: handed to a teammate who can make the same time, otherwise moved to the nearest open time within the next week. Jobs that still fit are left alone. Customers whose job changes are texted. The response lists what happened to each later job.
Body Type Required Notes
status "on_track" | "plus15" | "plus30" | "plus60" | "office_review" yes Extra minutes needed, or ask the office to review the job
note string, 1 to 300 characters no With office_review: what the office should look at
at string no When the tech answered, for a check-in queued offline. Recorded in the job history; the extension itself is applied now
bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/checkin \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"status":"plus30"}'
json
{
  "jobId": 1042,
  "newEnd": "2026-10-12T10:45",
  "changes": [
    { "jobId": 1043, "action": "handed_off", "local": "2026-10-12T10:30", "techs": ["Luis"], "customerTexted": true }
  ]
}

With on_track, newEnd is null and changes is [].

action Meaning Customer text
kept Still fits after the extension. Untouched None
handed_off Given to a teammate at the same time. techs is who goes now "Your … is now …", naming the new tech
new_time Moved to the nearest open time within a week. local is the new time "Your … is now …"
needs_call With local set: a job needing several people or several days, left in place for the office to sort out. With local: null: nothing fits within a week, so the job comes off the schedule With local set: none. With local: null: an apology with a link to pick a new time (or, if there are no times, that the office will call)

newEnd and local are company wall-clock times; techs are the first names of the people now on that job. customerTexted says whether that job's customer is texted about the change: false for kept and for needs_call with a time, and whenever homeowner messages are off for that job. Any extension also sends the job.running_late webhook to the company (see webhooks.md).

Errors:

Status error When
404 not_found The job isn't on your schedule
409 not_today As for stage taps: the job is on a later day, or more than a day ago
409 not_on_site The job isn't at on_site. Mark it arrived first
409 no_visit_today The job isn't on your schedule today (it's on another day), so it can't run late today
422 invalid_request Unknown status
422 invalid_at at out of range

#POST /v1/tech/location

Report where the tech is. The CrewLink app sends this about once a minute while the tech is on the clock, per the company's GPS policy. The latest position is kept as the live position (deleted after 30 days). A fix taken on the clock (30 minutes either side of the day's working hours and visits) is also added to the tech's location trail, thinned to one per 25 m or 60 s, which the company keeps for locationRetentionDays (default 90) for mileage and pay disputes (platforms.md); off the clock nothing goes into the trail. The On the way / Arrived / Done taps' lat/lng count as fixes too. It drives the office's live board (who will be late for their next start) and the fleet map (see office.md).

Body Type Required Notes
lat number, -90 to 90 yes
lng number, -180 to 180 yes
accuracy number, metres no Horizontal accuracy, as the phone reports it
heading number, 0 to 360 no Degrees clockwise from true north (when moving)
speed number, m/s no
at string no When the position was taken, for fixes queued offline. A fix older than the one CrewLink already has never replaces it
bash
curl -s -X POST $CREWLINK/v1/tech/location \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"lat":41.951,"lng":-87.66}'

Returns 204 No Content. Errors: 401, 403, 422 (invalid_request, invalid_at).


#Notes, photos and parts

#POST /v1/tech/jobs/{id}/notes

Add a note to a job. Notes appear in the job's history (GET /v1/tech/jobs/{id}) for everyone on the job, and in the job's message thread as a note from the tech, where the office reads it (Messaging).

Body Type Required Notes
text string, 1 to 4000 characters yes
at string no When it was written, for notes queued offline. The job history shows this time
bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/notes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"text":"Filter was 2 sizes too small; replaced."}'
json
{ "id": 5531 }

Returns 201. Errors: 401, 403, 404, 422. Send an Idempotency-Key so a retried note is added once.

#Photos

Photo bytes never pass through the CrewLink API. A photo is added in three steps:

sequenceDiagram
  participant App as Field app
  participant API as CrewLink API
  participant S as Photo storage
  App->>API: POST /v1/tech/jobs/{id}/photos {contentType, caption}
  API-->>App: 201 {photoId, uploadUrl}
  App->>S: PUT uploadUrl (image bytes, same Content-Type)
  S-->>App: 200
  App->>API: POST /v1/tech/jobs/{id}/photos/{photoId}/uploaded
  API-->>App: 200 {photoId, status: "uploaded"}
  1. Ask for an upload URL.

    bash
    curl -s -X POST $CREWLINK/v1/tech/jobs/1042/photos \
      -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
      -d '{"contentType":"image/jpeg","caption":"Filter before"}'
    json
    {
      "photoId": "6f1c2b8e-4d3a-4c1e-9a77-2f0d6b5e9c11",
      "uploadUrl": "https://<storage-host>/<path>.jpg?<signature parameters>"
    }

    Treat uploadUrl as opaque: don't change or re-encode it.

  2. Upload the bytes with an HTTP PUT straight to uploadUrl, within 15 minutes, with the same Content-Type you declared. Use the URL exactly as given and add no Authorization header.

    bash
    curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @filter-before.jpg

    Storage answers 200 on success. If the URL has expired, start again at step 1.

  3. Confirm the upload.

    bash
    curl -s -X POST $CREWLINK/v1/tech/jobs/1042/photos/6f1c2b8e-4d3a-4c1e-9a77-2f0d6b5e9c11/uploaded \
      -H "Authorization: Bearer $KEY"
    json
    { "photoId": "6f1c2b8e-4d3a-4c1e-9a77-2f0d6b5e9c11", "status": "uploaded" }

Only confirmed photos are shown to the office (GET /v1/office/jobs/{id}/photos, see office.md). When you confirm, CrewLink checks storage: nothing there yet is 409 not_uploaded (finish the PUT, then confirm again), and a photo over 25 MB or of another type than the contentType you declared is 422 upload_mismatch (the file and the photo are removed; start again). Confirm after storage returned a 2xx. A photo that is never confirmed is never shown. Photos are visible only to the company doing the job: a company that sent the job through CrewConnect does not see them.

#POST /v1/tech/jobs/{id}/photos

Body Type Required Notes
contentType "image/jpeg" | "image/png" | "image/heic" | "image/webp" yes Must match the Content-Type of the upload
caption string, up to 300 characters no Shown with the photo in the office

Returns 201 {photoId, uploadUrl}. The URL is valid for 15 minutes and only takes the Content-Type you declared (storage refuses another). Photos are up to 25 MB. Errors: 401, 403, 404 (job not on your schedule), 422.

#POST /v1/tech/jobs/{id}/photos/{photoId}/uploaded

Path Type Notes
id integer The job id
photoId UUID From step 1

No body. Returns 200 {photoId, status: "uploaded"}. Confirming twice is harmless. Errors: 401, 403, 404 (job not on your schedule, or no such photo on that job), 409 not_uploaded (nothing in storage yet), 422 upload_mismatch (over 25 MB or another type; the photo is removed), 422 (photoId isn't a UUID), 502 storage_unavailable (storage didn't answer; retry).

#GET /v1/tech/stock

The parts on the tech's truck, as the office last counted or moved them.

json
{ "stock": [ { "partId": 31, "sku": "FLT-1620", "name": "16x20 filter", "qty": 4 } ] }

Errors: 401, 403.

#POST /v1/tech/jobs/{id}/parts

Log parts used on a job. They come off the tech's truck count and are added to the job's invoice and profit-and-loss (see office.md).

Body Type Required Notes
partId integer yes A part of the tech's company
qty integer, 1 to 1000 yes
at string no When they were used, for entries queued offline

Send an Idempotency-Key header so a retry takes the parts off the truck once.

bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/parts \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"partId":31,"qty":2}'
json
{ "id": 877, "leftOnTruck": 2 }

Returns 201. Errors:

Status error When
404 not_found The job isn't on your schedule, or no such part
409 not_enough The truck doesn't have that many. Count it, or ask the office to correct the count
422 invalid_request Bad qty
422 invalid_at at out of range

#Calling the customer

#POST /v1/tech/jobs/{id}/call

Get a number to call or text this job's customer through. The customer sees a CrewLink number, not the tech's, and the tech never sees the customer's real number. The customer can call or text back through the same number for a day.

No body.

bash
curl -s -X POST $CREWLINK/v1/tech/jobs/1042/call -H "Authorization: Bearer $KEY"
json
{ "dial": "+13125550147" }

Dial or text dial from the tech's own mobile (the one on file with the office): the call is connected only from that phone. Each call to this endpoint sets up a fresh connection, so ask for the number each time rather than storing it.

Errors:

Status error When
404 not_found The job isn't on your schedule
409 no_phone The tech's mobile isn't on file ("Ask the office to add your mobile number."), or the customer has no usable number. Both numbers must be US or Canadian
503 not_configured Masked calling isn't available on this deployment

#Linking a calendar

A tech can link their own Google or Outlook calendar so personal appointments never get booked over:

  • Busy time comes in. Busy times in the linked calendar for the next 21 days block bookings for that tech, on the booking page, in the office and through CrewConnect.
  • Jobs go out. Every CrewLink job on the tech's schedule for the next 21 days is written into the calendar, titled with the service and the customer's first name (for example Seasonal tune-up · Jamie), with the address and the job number. These entries are marked free, so they never count as busy time themselves. They follow moves and are removed when a job is cancelled.
  • Sync runs every 5 minutes, and right after linking. The tech can also ask for a sync now.

The flow:

sequenceDiagram
  participant App as Field app
  participant API as CrewLink API
  participant P as Google / Microsoft
  App->>API: POST /v1/tech/calendar/connect {provider}
  API-->>App: {url}
  App->>P: open url in a browser; tech signs in and consents
  P->>API: GET /v1/calendar/callback/{provider}?code=...&state=...
  API-->>App: redirect to the CrewLink web app, ?calendar=connected or failed
  App->>API: GET /v1/tech/calendar (status)

#POST /v1/tech/calendar/connect

Start linking a calendar. Returns the provider's sign-in URL; open it in a browser on the tech's device. The URL works once and expires after 15 minutes.

Body Type Required Notes
provider "google" | "outlook" yes
bash
curl -s -X POST $CREWLINK/v1/tech/calendar/connect \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"provider":"google"}'
json
{ "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=https%3A%2F%2Fgo.crewlink.ai%2Fv1%2Fcalendar%2Fcallback%2Fgoogle&...&state=..." }

After consent, the provider sends the browser back to CrewLink, which links the calendar, runs a first sync and then redirects to the CrewLink web app with ?calendar=connected (or ?calendar=failed if consent was refused, the link expired or was already used, or the provider rejected it). Linking the same provider again replaces the earlier link.

Errors: 401, 403, 422, 503 not_configured (that provider isn't available on this deployment).

#GET /v1/tech/calendar

The tech's linked calendars.

json
{
  "calendars": [
    { "provider": "google", "account": "maria@example.com", "status": "ok", "lastSyncAt": "2026-10-12T15:35:00.000Z", "lastError": null }
  ]
}
Field Notes
account The calendar account's email, when the provider shares it
status ok, or error when the last sync failed. CrewLink keeps retrying
lastError A plain-language message when status is error. If it lasts, unlink and link again

Errors: 401, 403.

#POST /v1/tech/calendar/sync

Sync every linked calendar for this tech now, then return the same body as GET /v1/tech/calendar. Useful right after the tech adds an appointment. No body. Errors: 401, 403.

#DELETE /v1/tech/calendar/{provider}

Unlink a calendar. Syncing stops.

Path Type Notes
provider google | outlook
json
{ "removed": true }

removed is false if nothing was linked. Busy times brought in from that calendar stop blocking bookings right away (past ones stay on the record). Job entries already written into the calendar are not deleted from it.

Errors: 401, 403, 422.

#GET /v1/calendar/callback/{provider}

The OAuth redirect target. Google or Microsoft sends the tech's browser here after consent; you never call it yourself. It takes code, state and optionally error as query parameters and always answers 302 Found, redirecting to the CrewLink web app: /tech?calendar=connected, or /tech?calendar=failed for a bad or reused state, a provider error or refused consent. No token is needed: the one-time state from POST /v1/tech/calendar/connect identifies the tech.

The office can see who has linked a calendar and whether it is syncing with GET /v1/office/calendars (office.md).

#Wins

A tech's own wins, all from their own taps and reviews (how each number is worked out: office.md, Wins). Jobs worked by a crew count for everyone on it. Nothing here shows another tech's numbers except the leaderboard's top 3, by first name and last initial, when the company has turned the leaderboard on.

#GET /v1/tech/me/wins

bash
curl -s $CREWLINK/v1/tech/me/wins -H "Authorization: Bearer $KEY"
json
{
  "rookie": null,
  "streak": { "current": 12, "best": 25, "startedOn": "2026-10-06", "atRisk": true, "paused": false },
  "streaks": [
    { "type": "on_time", "name": "On-time arrivals", "unit": "arrivals", "description": "…", "current": 12, "best": 25, "startedOn": "2026-10-06", "atRisk": true, "paused": false, "nextMilestone": 25 },
    { "type": "on_the_way", "name": "On-the-way taps", "unit": "days", "description": "…", "current": 4, "best": 9, "startedOn": "2026-10-09", "atRisk": false, "paused": false, "nextMilestone": 5 },
    { "type": "five_star", "name": "Five-star reviews", "unit": "reviews", "description": "…", "current": 3, "best": 11, "startedOn": "2026-10-01", "atRisk": false, "paused": false, "nextMilestone": 5 },
    { "type": "no_checkin", "name": "Inside the planned time", "unit": "jobs", "description": "…", "current": 18, "best": 40, "startedOn": "2026-10-02", "atRisk": false, "paused": false, "nextMilestone": 25 }
  ],
  "week": { "from": "2026-10-12", "to": "2026-10-18", "arrivals": 6, "onTime": 6, "onTimeRate": 100, "done": 6, "fiveStar": 1, "drivesMeasured": 5, "driveMinutesSaved": 7.5, "bestDayJobs": 4 },
  "personalBest": { "jobs": 6, "date": "2026-09-30" },
  "totals": { "arrivals": 310, "onTimeRate": 94.2, "early": 120, "done": 305, "fiveStar": 41, "reviews": 48, "firstTimeFixRate": 96.1, "ranLong": 22, "headsUp": 17, "checkinHonestyRate": 77.3 },
  "badges": [
    { "key": "clockwork", "name": "Clockwork", "description": "On-time arrivals in a row …", "tier": "silver", "value": 25, "next": { "tier": "gold", "threshold": 50 }, "earnedAt": "2026-10-02T15:12:00.000Z" },
    { "key": "closer", "name": "Closer", "description": "Jobs finished", "tier": "silver", "value": 305, "next": { "tier": "gold", "threshold": 500 }, "earnedAt": "2026-08-14T21:40:00.000Z" }
  ],
  "celebrations": [
    { "id": 77, "kind": "badge", "title": "You hit a 25-visit on-time streak", "badge": { "key": "clockwork", "name": "Clockwork", "tier": "gold" }, "streak": null, "at": "2026-10-02T15:12:00.000Z" }
  ],
  "leaderboard": { "enabled": true, "hidden": false, "boards": [
    { "key": "on_time", "name": "On-time arrivals", "unit": "pct", "top": [{ "rank": 1, "personId": 12, "name": "Maria G.", "value": 100, "detail": "6 of 6 arrivals", "rookie": false }], "you": { "rank": 1, "personId": 12, "name": "Maria G.", "value": 100, "detail": "6 of 6 arrivals", "rookie": false, "qualified": true, "hidden": false } }
  ] }
}
Field Notes
rookie { "daysLeft": n } during the first 30 days from the tech's first visit, else null
streaks[] Every streak, on time first (what each counts): current, best, startedOn, atRisk (the next thing that could end it is close: for on time, the next stop starts within 30 minutes and you're not there yet), paused (today is a day off or time off: it waits, it never breaks), nextMilestone (5, 10, 25, 50, 100, then every 100). Show the on-time one first and big; when current is 0 say only that a new streak starts with the next stop
streak The on-time streak on its own (the same numbers as streaks[0])
week Monday to Sunday, company time. driveMinutesSaved: the planned drive minutes minus the real ones on the drives measured (negative: over plan). bestDayJobs: most jobs finished in one day this week
personalBest Most jobs finished in one day, ever
totals.checkinHonestyRate Of jobs that ran long (ranLong), the percent where a check-in with the extra time came before the planned finish (headsUp)
badges[] Every badge: tier earned (null if none yet; kept once earned), value now, and next tier to go for (null at gold)
celebrations[] New badges, streak milestones (streak: { type, length }) and personal bests the app hasn't shown yet (last 14 days, up to 5, oldest first). Show one at a time, then mark them shown
leaderboard enabled when the company turned it on. Each board has the top 3 and you, the tech's own row: rank is null unless they're in the top 3; qualified is false below 5 arrivals (on time) or with no five-star review (five-star) this week; hidden when they chose to stay off the board. The rest of the board is never returned

Errors: 401, 403.

#PATCH /v1/tech/me/wins/settings

bash
curl -s -X PATCH $CREWLINK/v1/tech/me/wins/settings -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{ "hideFromLeaderboard": true }'

Response 200: { "hideFromLeaderboard": true }. Hidden, the tech isn't ranked or shown to anyone else; they still see their own row. Errors: 401, 403, 422.

#POST /v1/tech/me/wins/celebrated

Mark celebrations as shown so they aren't returned again. Body: { "ids": [77] } (1 to 50 ids from celebrations). Ids that aren't this tech's are ignored. Response 204. Errors: 401, 403, 422.


#Messages

Techs message the office and their job's crew with the Messaging API, using the same tech token:

  • The office line (GET /v1/messaging/office-line, POST /v1/messaging/office-line/messages): one conversation between the tech and everyone in the office.
  • Job threads (GET /v1/messaging/jobs/{jobId}, POST /v1/messaging/jobs/{jobId}/messages): one per job, with the office and everyone on the job. On the way, arrived, done, check-ins and notes show in it as they happen.
  • Everyone, the company channel, which techs read (and post in, if the owner allows it), and direct messages with the office (with other techs only if the owner allows it).

The two POSTs above take the job id or nothing, not a conversation id, so a message written offline can wait in the same queue as the taps: give it an Idempotency-Key when it's written and resend it unchanged (Offline taps and retries). Unread counts for a badge: GET /v1/messaging/unread.


#My pay

#GET /v1/tech/pay

What the tech has earned this pay week so far (technician pay): jobs completed and their pay, the miles driven over each day's route and the mileage pay, bonuses and deductions, and the total; what closed weeks still owe them; and their last payment. Pay accrues when they tap done.

json
{
  "visible": true,
  "pay": {
    "period": { "start": "2026-10-12", "end": "2026-10-18", "status": "open" },
    "jobs": 6, "jobPayCents": 54000, "distance": 141.2, "unit": "mi", "mileageCents": 3530, "adjustmentsCents": 0, "totalCents": 57530, "currency": "USD",
    "owedCents": 0,
    "lastPaid": { "periodStart": "2026-10-05", "totalCents": 81200, "paidAt": "2026-10-13T16:00:00.000Z" }
  }
}

{ "visible": false, "pay": null } when the owner doesn't show pay in the app (techsSeePay). Errors: 401, 403.

#My hours and home

A tech can change their own working hours (several windows a day) and home address, when the owner allows it (techsEditHours, on by default). A change takes effect tomorrow in the company's time zone, so today's jobs stay as booked; the office sees it waiting on the team.

#GET /v1/tech/profile

json
{
  "name": "Maria Gomez",
  "designation": "w2",
  "verified": true,
  "home": { "lat": 41.93, "lng": -87.7 },
  "address": { "line1": "2150 W Roscoe St", "city": "Chicago", "region": "IL", "postalCode": "60618" },
  "availability": [[[480, 720], [780, 1020]], [[480, 1020]], [[480, 1020]], [[480, 1020]], [[480, 1020]], [], []],
  "pendingChange": null,
  "canEdit": true,
  "managedBy": null,
  "managedByName": null
}

availability: Monday first, each day's windows as [start, end] minutes after midnight; [] off. pendingChange: { from, availability, address } when a change is waiting. canEdit is false when the owner doesn't let techs change these, or when an integration owns them: then managedBy is its slug and managedByName its name (e.g. "Acme"), and the app says "Your hours, time off and home are managed in Acme. Change them there." instead of the edit button.

#PATCH /v1/tech/profile

Body: availability (seven lists of windows, at most 6 a day, not overlapping) and/or address with homeLat and homeLng (pick it from address search). Sending again before tomorrow replaces the waiting change.

Response 200: the profile, with pendingChange.from = tomorrow. Errors: 403 not_allowed (the owner manages hours in the office), 409 managed_by_integration (an integration owns your hours and home: managedBy, managedByName; office.md), 422 invalid_request (overlapping windows, an address without its position, or nothing sent).


#Directions

In-app turn-by-turn navigation: the next stop, and a driving route to it. A typical flow when the tech taps On my way:

  1. POST /v1/tech/jobs/{id}/stage with en_route (texts the customer, as always).
  2. GET /v1/field/jobs/{id}/route?from=<lat>,<lng> with the phone's position, and show the steps.
  3. Watch the position. A step is done when the phone is within about 30 m of its maneuver (or past it along the line). More than about 60 m off the line for 10 seconds: ask for the route again from where the phone is. Within about 75 m of to: offer Arrived, which is POST /v1/tech/jobs/{id}/stage with on_site.

Nothing about the route is stored, and from is not saved as a location report (send those to POST /v1/tech/location).

#GET /v1/field/next-stop

The tech's next stop today: the first stop in plan order that isn't done (one they're on the way to, or on site at, counts). Use it for an "On my way to my next stop" button.

bash
curl -s $CREWLINK/v1/field/next-stop -H "Authorization: Bearer $KEY"
json
{ "jobId": 1042 }

jobId is null when nothing is left today. Errors: 401, 403.

#GET /v1/field/jobs/{id}/route

A driving route from from to the job, with turn-by-turn steps.

Query Type Notes
from lat,lng Optional. Where the phone is, e.g. 41.8902,-87.6511. Without it the route starts at the tech's last reported position (from the last 12 hours), else the stop before this one today, else where their day starts.
bash
curl -s "$CREWLINK/v1/field/jobs/1042/route?from=41.8902,-87.6511" -H "Authorization: Bearer $KEY"
json
{
  "jobId": 1042,
  "to": { "lat": 41.955, "lng": -87.675, "address": "2501 N Lincoln Ave, Chicago" },
  "from": { "lat": 41.8902, "lng": -87.6511 },
  "provider": "mapbox",
  "distanceMeters": 8123,
  "durationSeconds": 1260,
  "eta": "2026-10-12T15:51:00.000Z",
  "arriveBy": "2026-10-12T17:00:00.000Z",
  "geometry": { "type": "LineString", "coordinates": [[-87.6511, 41.8902], [-87.6511, 41.9], [-87.675, 41.955]] },
  "steps": [
    { "instruction": "Drive north on N Halsted St.", "name": "N Halsted St", "distanceMeters": 1100, "durationSeconds": 120,
      "maneuver": { "type": "depart", "modifier": null, "location": [-87.6511, 41.8902], "bearingAfter": 0 },
      "voice": "Drive north on North Halsted Street, then turn left." },
    { "instruction": "Turn left onto W Fullerton Ave.", "name": "W Fullerton Ave", "distanceMeters": 800, "durationSeconds": 100,
      "maneuver": { "type": "turn", "modifier": "left", "location": [-87.6511, 41.9], "bearingAfter": 270 },
      "voice": "Turn left onto West Fullerton Avenue.",
      "lanes": [
        { "indications": ["left"], "valid": true, "active": true, "validIndication": "left" },
        { "indications": ["straight"], "valid": false, "active": false },
        { "indications": ["right"], "valid": false, "active": false }
      ] },
    { "instruction": "You have arrived.", "name": "N Lincoln Ave", "distanceMeters": 0, "durationSeconds": 0,
      "maneuver": { "type": "arrive", "modifier": null, "location": [-87.675, 41.955], "bearingAfter": 0 },
      "voice": null }
  ],
  "maxspeed": [{ "speed": 30, "unit": "mph" }, null],
  "mapStyle": {
    "url": "https://api.mapbox.com/styles/v1/mapbox/navigation-day-v1",
    "token": "pk.<mapbox token>",
    "style": "crewlink-nav",
    "version": 1,
    "themes": ["light", "blue", "dark"]
  }
}
Field Meaning
to The job: its position and one-line address (null when it has none).
from Where this route starts (what you sent, or the fallback above).
provider mapbox: drive times with live traffic. osrm: free-flow road times, used when the traffic provider is unavailable.
distanceMeters, durationSeconds The whole drive.
eta When the tech gets there leaving now (UTC).
arriveBy When the job starts (UTC), or null.
geometry The route as a GeoJSON LineString, [lng, lat] pairs.
steps In driving order. The first is the departure, the last the arrival. Each has the instruction to show, the road name, the step's length and time (from its maneuver to the next), the maneuver (type such as depart, turn, merge, on ramp, off ramp, fork, end of road, roundabout, continue, new name, arrive; modifier such as left, slight right, uturn, straight, or null; location as [lng, lat]; bearingAfter in degrees from north, or null) and voice, a line to speak as the tech reaches that maneuver (null when there isn't one; speak instruction then). Optional lanes: lane guidance for that maneuver, left to right, when the provider knows the lanes (left out otherwise): each lane's indications (the painted arrows: left, slight left, straight, slight right, right, sharp left, sharp right, uturn, none), valid (the maneuver can be made from it) and, from Mapbox, active (the best lane) and validIndication (the arrow the route follows).
maxspeed Optional. Posted speed limits along the route: one entry per segment of geometry (coordinates.length - 1 entries), { speed, unit } with unit km/h or mph, or null where no limit is known. Left out when the provider knows none (OSRM, or Mapbox without data there).
mapStyle A map to draw under the route, or null: draw the route, the phone's position and the job without map tiles. token is a public map token (pk.) that may be used in a browser or app. style is crewlink-nav: CrewLink's own navigation style, which the apps carry in light, blue and dark (themes) and draw on Mapbox's vector tiles with token; version is the style version the server expects. url is Mapbox's stock navigation style, kept for apps from before style existed. Maps drawn with these tiles must show the Mapbox wordmark and "© Mapbox © OpenStreetMap Improve this map".

Routes are kept for a few minutes: asking again from about the same place gives the same answer.

Errors:

Status error When
401 unauthorized No token, or a bad one
403 forbidden Not a tech token
404 not_found The job isn't on your schedule (another tech's, another company's, cancelled or unknown)
422 invalid_request from isn't lat,lng, or isn't a real position
422 no_origin No from, and nowhere to start from (no recent position, no earlier stop, no start location)
429 rate_limited More than 30 routes a minute for this tech
502 route_unavailable No driving route was found right now: open the address in a maps app
503 not_configured Directions aren't set up on this server

#The first-run tour

The tech app shows a short intro the first time a tech signs in, and the web tech view a guided tour. Where they are in it is kept per person with GET /v1/me/tour and PATCH /v1/me/tour (any role, the caller's own state only), so it shows once and resumes on another device. The app uses the tour id tech-app; the web tech view uses tech. Request and response: office.md.

bash
curl -X PATCH $CREWLINK/v1/me/tour -H "authorization: Bearer $TECH_TOKEN" \
  -H 'content-type: application/json' -d '{"tour": "tech-app", "status": "completed"}'

  • messaging.md: the office line, job threads, the company channel and who can message whom.
  • office.md: creating tech users, the live board, job photos, parts and stock, linked calendars.
  • webhooks.md: job.en_route, job.arrived, job.completed and job.running_late payloads, and how to verify signatures.
  • crewconnect.md: jobs that come in from other companies.
  • sign-in.md: email sign-in for techs.

CrewLink API · Reference · Changelog