CrewLink API

Platforms: building on CrewLink

A platform is another product that runs CrewLink for the companies that let it. For example, your platform's clients (solar installers) schedule site surveys in your platform, and your platform's backend for scheduling is this API. CrewLink finds a time a qualified tech can actually make (their other jobs, calendar, skills and the drive at that hour), books it, texts the homeowner, runs the field app, and tells your platform what happened through webhooks.

This page is for the engineers who build a platform on CrewLink.

#How it fits together

sequenceDiagram
  participant C as Platform client (installer)
  participant S as Platform backend
  participant L as CrewLink API
  participant T as Tech (field app)
  C->>S: Schedule a site survey
  S->>L: POST /v1/office/availability (open times)
  S->>L: POST /v1/office/jobs (book, externalRef = your id)
  L-->>S: webhook booking.created
  S->>L: POST /v1/office/jobs/{id}/attachments (proposal PDF)
  T->>L: on the way, arrived, done
  L-->>S: webhooks job.en_route, job.arrived, job.completed
  S->>L: GET /v1/office/jobs/{id} (detail, files, timeline)
  • Companies are CrewLink accounts (an installer, or your platform's own crew). Each company decides whether your platform may act for it (a grant), and with which scopes.
  • One key for the whole platform. Every company-level request names the company in a header.
  • Company isolation holds: a request for one company never sees another's jobs. A company that hasn't granted your platform access answers 404, exactly as if it didn't exist.

#Keys, companies and scopes

#The platform key

Authorization: Bearer pl_<platform id>.<secret>

CrewLink creates it (cli.ts platform-create) and hands it over in a file, never in chat or email. It is shown once; only a hash is stored. Keep it on your platform's servers (a secret manager), never in a browser or an app. If it leaks, ask CrewLink for a new one: the old one stops working at once.

#Naming the company

Every /v1/office/... request also sends:

X-CrewLink-Company: <company slug or id>

The slug (acme-test, northline) or the UUID both work. GET /v1/platform/grants lists the companies that granted your platform access.

Missing or wrong Answer
No Authorization, or a bad or disabled key 401 unauthorized
No X-CrewLink-Company 400 company_required
A company that never granted your platform, revoked it, or doesn't exist 404 not_found

#Scopes

A company grants any of:

Scope Lets your platform
read See jobs, the schedule, the catalog, open times and files (every GET)
write Everything read does, plus book, hold, move, cancel and complete jobs, add notes, flag attention and add or remove files (every POST, PUT, PATCH, DELETE that isn't off limits)
webhooks Subscribe to the company's events: its events reach your platform's platform-wide subscription, and your platform may add company-level endpoints with /v1/office/webhooks. Granting again without webhooks stops both at once, as revoking does. Never the company's messages (message.*)

A request outside the grant answers 403 scope ("This needs the write scope; Northline granted read."). Within its scopes your platform acts as the company's owner, except on these, which a platform key never reaches whatever it was granted (403 forbidden):

  • the field app (/v1/tech/...): that is for techs' own tokens;
  • users and their tokens (/v1/office/users...), invitations (/v1/office/invitations...), changes to client companies (/v1/office/clients..., read is fine), the company API key and the integration key;
  • company settings (PATCH /v1/office/settings, PATCH /v1/office/brain/settings);
  • the CrewLink plan and billing changes; payouts; payments;
  • CrewConnect writes (connections, pay terms, sending jobs to partners);
  • the company's own messages, wins and leaderboard, sign-in and per-person settings (/v1/messaging, /v1/office/wins, /v1/auth, /v1/me);
  • platform grants themselves (a platform can't grant itself more);
  • people's sign-ins, papers and pay: resetting a sign-in, people's documents (W-9s, IDs, licences), credentials, pay rates, pay periods and adjustments (/v1/office/team/{id}/reset-sign-in, /documents, /credentials, /pay-rates, /v1/office/credentials, /v1/office/pay...);
  • invoices (sending, voiding, creating one for a job) and recurring work (it texts homeowners on every cycle);
  • whole-company reports, exports and devices: /v1/office/reports..., /kpis, /exports/..., jobs.csv, /brain/..., /fleet, /calendars, and CrewLink's own /v1/ops pages.

One exception for client companies (kind: "client", the installers a provider set up with POST /v1/office/clients): when a client company grants your platform write, your platform may file requests with its provider as that client (POST /v1/connect/connections/{id}/requests, crewconnect.md) and take back one it filed (POST /v1/connect/requests/{jobId}/cancel), with X-CrewLink-Company set to the client company. Every other CrewConnect write stays off limits. The client's owner grants it with the client's own key (POST /v1/office/clients/{id}/api-key issues one). For importing or booking on the provider's side, the provider grant and clientCompanyId on POST /v1/office/jobs are simpler: the job carries your external ids, which a client-filed request can't.

#Granting and revoking (the company's side)

The company's owner grants and revokes from the office API (a screen in the web app comes later). CrewLink ops can also do it (cli.ts platform-grant), which is how the sandbox and the demo companies are set up.

bash
# The installer's owner, with their own key (not your platform's)
curl -X POST $CREWLINK/v1/office/platforms/acme/grant \
  -H "authorization: Bearer $OWNER_KEY" -H 'content-type: application/json' \
  -d '{"scopes": ["read", "write", "webhooks"]}'
json
{ "platform": { "slug": "acme", "name": "Acme" }, "scopes": ["read", "webhooks", "write"], "homeownerMessaging": "on", "grantedAt": "2026-10-12T15:30:00.000Z", "revokedAt": null, "grantedBy": "0b6e1c52-6a8f-4f8e-9a51-2a8c3f0d9e11" }
  • Granting again replaces the scopes. The grant body may also carry homeownerMessaging ("on" or "off", below); left out, a grant keeps its option. GET /v1/office/platforms lists the company's grants (revoked ones too). DELETE /v1/office/platforms/acme/grant revokes.
  • A new grant sends platform.grant.created to your platform; a revoke sends platform.grant.revoked. After a revoke your platform's key gets 404 for that company at once, its company-level endpoints there are turned off, and its platform-wide subscription stops getting that company's events. Jobs your platform booked stay on the company's schedule.

#Homeowner messaging

Your platform talks to the homeowner itself, so jobs your platform books never text or email the homeowner. Three switches decide whether a job may text its homeowner; any one off silences it:

Switch Set by Default
The job's own flag, notify (reads: homeownerTexts) notify: false on POST /v1/office/holds, POST /v1/office/jobs, POST /v1/office/jobs/{id}/move true
The company's setting, homeownerMessaging The owner: PATCH /v1/office/settings { "homeownerMessaging": "off" } (GET /v1/office/me shows it) on
  • "Silenced" means no confirmation, reminder, on-the-way text or ETA, running-late move, reschedule, cancellation, review link or invoice text or email to the homeowner for that job. Nothing is queued, and the text worker drops anything queued before a switch went off (it shows as cancelled in GET /v1/office/jobs/{id}/texts). Webhooks to the company and to your platform are unchanged.
  • A job your platform books while its grant has homeownerMessaging: "off" (including an emergency, POST /v1/office/emergencies) is booked with notify: false whatever you send, and remembers which platform booked it, so the grant option keeps applying to it. Turning the grant option back on doesn't wake a job booked while it was off (its own flag is false).
  • On a hold, notify: false is kept when the hold is booked; the hold answer says notify.
  • POST /v1/office/jobs/{id}/move notify: false sends nothing about the move and keeps the job quiet from then on; notify: true turns its texts back on; left out, the job keeps what it had. POST /v1/office/jobs/{id}/cancel notify: false and .../complete silent: true already existed for one action each.
  • Every job read (GET /v1/office/jobs, the detail, intake) has homeownerTexts: false when any switch is off. The office's job drawer shows a "Homeowner texts off" badge.
  • GET /v1/platform/grants shows each grant's homeownerMessaging.
  • A new grant on a client company a provider manages (POST /v1/office/clients) starts with the platform's option on that provider, so a new installer of a provider your platform keeps quiet is quiet too.

#What your platform sees of people

  • Techs and office staff appear as first name and last initial ({ "id": 3, "displayName": "Maria G." }, never the full name) everywhere a platform reads: job lists and details, booking and move answers, the team list, time off, the schedule, the live board, requests and their options, intake, emergencies, error messages, and every webhook to your endpoints (company or platform-wide). The company's own keys see full names. The live board shows a tech's position only while they're working a job right now and the fix is fresh (never where they are after hours), like the dispatch board. Webhooks never carry the company's messages (message.*), whatever you subscribed to.
  • Messages are worded the same way. A refusal's message (409 slot_taken: "That's outside Maria G.'s hours ...", 409 tech_busy: "Maria G. is already on job 4502 then ..."), a queued job's reason and its intakeReason on reads and the dispatch board, attentionReason, and those fields in webhooks to your endpoints name techs as "First L.", so you can show them to your clients. The company's own keys and endpoints get full names.

Full names for a company you run (peopleNames). Each grant has peopleNames: short (the default) is everything above; full shows that company's people by their full names everywhere (answers, worded fields, webhooks), for a platform that operates the company itself. Your platform's grants on acme-crew and acme-test are full; every other grant, its client companies' included, is short. The company's owner sets it with POST or PATCH /v1/office/platforms/{slug}/grant { "peopleNames": "full" }; GET /v1/platform/grants and the grant answers show it. With full on a client company, GET /v1/connect/requests lists the techs' first names again (a client's own people never see them).

  • Customers (homeowners) appear with name, phone and email on the job detail, because your platform booked them and needs them. Treat them as your clients' customers' data.

#Endpoints you will use

All need the platform key. Rows marked C also need X-CrewLink-Company.

Method Path Scope What
GET /v1/platform/grants The companies that granted your platform access, and the scopes
POST /v1/platform/webhooks Subscribe one URL to every granted company's events (secret shown once)
GET /v1/platform/webhooks Your platform's platform-wide subscriptions
PATCH /v1/platform/webhooks/{id} { "historyEvents": true } turns the history emissions on (off by default); any endpoint this platform made
DELETE /v1/platform/webhooks/{id} Remove one
GET /v1/platform/webhooks/{id}/deliveries The delivery log: what was queued, attempts, last status and error (event, jobId, status, limit, before)
POST /v1/platform/webhooks/{id}/deliveries/{deliveryId}/retry Send one delivery again now
GET /v1/office/catalog C read The company and its bookable services, with category
POST /v1/office/availability C read Open start times at an address (excludeJobId for reschedules)
POST /v1/office/first-available C read The first open time (same body and stepMinutes)
POST /v1/office/holds C write Hold a time for 5 minutes
POST /v1/office/jobs C write Book: a held time (holdId + holdSecret) or a time (serviceId + start + place)
GET /v1/office/jobs C read Jobs: filters, updatedSince, cursor pages, lookup by externalRef
GET /v1/office/jobs/{id} C read One job in full: customer, visits, timeline, files, texts count
POST /v1/office/jobs/{id}/move C write Reschedule (new time and/or techs)
POST /v1/office/jobs/{id}/cancel C write Cancel
POST /v1/office/jobs/{id}/history C write Correct a cancelled or done import's old time and techs (no checks, nothing sent)
POST / GET /v1/office/requests C write / read File a visit request for a client company (clientCompanyId, externalRef); list them
POST /v1/office/requests/{id}/decline C write Take back a request that still waits (cancel answers 404 for it)
GET /v1/office/intake/{id}/options C read Who could take a job with no time on a date, with their open starts
POST /v1/office/jobs/{id}/complete C write Mark done from your platform (silent or not)
POST /v1/office/jobs/{id}/notes C write Add an office note
POST / DELETE /v1/office/jobs/{id}/attention C write Flag (or clear) "needs attention"
POST /v1/office/jobs/{id}/attachments C write Add a file: get an upload URL
POST /v1/office/jobs/{id}/attachments/{attachmentId}/uploaded C write Confirm the upload
POST /v1/office/jobs/{id}/attachments/upload C write Add a file in one call (up to 20 MB)
GET /v1/office/jobs/{id}/attachments C read A job's files with download links
DELETE /v1/office/jobs/{id}/attachments/{attachmentId} C write Remove a file your platform added
GET /v1/office/jobs/{id}/texts C read The texts the homeowner got
GET /v1/office/dispatch C read The dispatch board for a day in one read (ETag / If-None-Match: 304 when unchanged)
GET /v1/office/team C read The company's techs (with email and phone); ?email= (theirs or their sign-in email) or ?externalSource=&externalRef=
GET /v1/office/team/{id} C read One tech, the same object as a list item
GET / PUT /v1/office/availability-management C read / write Who owns the techs' availability: claim it (acme) or hand it back (null)
POST, PATCH /v1/office/team, /v1/office/team/{id} C write Add or change a tech, with externalRefs, email and phone
PUT /v1/office/team/{id}/availability C write Replace a tech's weekly hours (one or several windows a day)
POST / GET /v1/office/blocks C write / read Time off, with externalRefs; find it again with ?externalSource=&externalRef=
DELETE /v1/office/blocks/{id} C write Remove time off
POST / GET / DELETE /v1/office/team/{id}/bases C write / read Where a tech starts and ends their days for a while (lodging)
POST / GET / PATCH / DELETE /v1/office/webhooks C webhooks Company-level endpoints (prefer the platform-wide one); PATCH /v1/office/webhooks/{id} { "events", "historyEvents" } on one this platform made
GET, POST /v1/office/webhooks/{id}/deliveries, .../{deliveryId}/retry C webhooks The log of a company-level endpoint your platform made, and a resend
GET /v1/office/dispatch/live C read Live positions, field statuses and risk for a day
GET /v1/office/dispatch/routes C read The route each tech is on (ETag / 304)
GET /v1/office/sites C read Sites: one per property per client company, with every visit (?externalSource=acme&externalRef=, updatedSince)
GET / PATCH /v1/office/sites/{id} C read / write One site with its timeline; set the site's own externalRefs
POST /v1/office/sites/{id}/merge C write Merge another site (fromSiteId) into this one
POST /v1/office/sites/{id}/split C write Take a visit (jobId) out to a new site, or to toSiteId
GET /v1/office/team/{id}/locations C read A tech's location trail for a day: fixes, road miles, stops (grants with full names only)
POST /v1/office/team/{id}/locations C write Fixes from your platform's own tech app (up to 500 a call)
POST /v1/office/route C read A driving route with turn-by-turn steps
POST /v1/office/route-matrix C read Road distance and time, up to 25 x 25 (mileage and pay)
POST / GET /v1/office/jobs/{id}/external-messages C write / read Mirror a message into a job's conversations; read them ({id} may be ref:<your id>)
POST / GET /v1/office/external-threads/{threadKey}/messages C write / read The same for a DM or group outside a job
GET /v1/office/sites/{id}/external-messages C read Every visit's mirrored messages at one site
PATCH / DELETE /v1/office/external-messages/{id} C write Edit a mirrored message, or delete it (a tombstone); {id} may be ref:<your message id>
POST /v1/office/external-messages/backfill C write Up to 500 mirrored messages in one call, with their original times
POST /v1/office/external-messages/attachments C write Upload a file for a mirrored message (or send its https URL: CrewLink copies it)
GET / POST /v1/office/clients/{clientId}/share-links C read / write A client company's share links: booking links its sales reps open with no account
GET / PATCH / DELETE /v1/office/clients/{clientId}/share-links/{linkId} C read / write One link with its audit trail; change it; revoke it
POST /v1/office/clients/{clientId}/share-links/{linkId}/rotate C write A new URL (the old one stops at once)

The public booking endpoints (/v1/public/{slug}...) keep working too, but they are made for a website: per-IP limits, a 3-hold cap per IP and a one-hour lead. From your platform's servers use the keyed endpoints above.

Full request and response schemas: the interactive reference at /docs (generated from the code), and office.md.

#The booking flow, end to end

bash
export CREWLINK=https://go.crewlink.ai          # staging; production base URL to follow
export PK='pl_<platform id>.<secret>'
export CO=acme-test
H=(-H "authorization: Bearer $PK" -H "x-crewlink-company: $CO" -H 'content-type: application/json')

#1. Which companies, which services

bash
curl -s $CREWLINK/v1/platform/grants -H "authorization: Bearer $PK"
json
{
  "platform": { "slug": "acme", "name": "Acme" },
  "grants": [
    { "company": { "id": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "slug": "acme-test", "name": "Acme (test)", "timezone": "America/Chicago" }, "scopes": ["read", "webhooks", "write"], "grantedAt": "2026-10-12T15:30:00.000Z", "revokedAt": null }
  ]
}
bash
curl -s $CREWLINK/v1/office/catalog "${H[@]}"
json
{
  "company": { "name": "Acme (test)", "slug": "acme-test", "timezone": "America/Chicago", "country": "US", "units": "mi" },
  "services": [
    { "id": 41, "name": "Site survey", "minutes": 90, "peopleNeeded": 1, "days": 1, "priceCents": null, "depositCents": 0, "currency": "USD", "category": "residential" },
    { "id": 42, "name": "Commercial site survey", "minutes": 180, "peopleNeeded": 2, "days": 1, "priceCents": null, "depositCents": 0, "currency": "USD", "category": "commercial" }
  ]
}

category is residential, commercial, other or null (not set). Branch your flows on it, not on the service name. Companies set it on their services (PATCH /v1/office/services/{id} { "category": "commercial" }).

#2. Open times at the address

bash
curl -s -X POST $CREWLINK/v1/office/availability "${H[@]}" \
  -d '{"serviceId": 41, "lat": 41.955, "lng": -87.675, "from": "2026-11-03", "days": 7}'
json
{
  "timezone": "America/Chicago",
  "slots": [
    { "start": "2026-11-03T14:30:00.000Z", "local": "2026-11-03T08:30" },
    { "start": "2026-11-03T14:45:00.000Z", "local": "2026-11-03T08:45" }
  ],
  "outsideArea": false,
  "nearestKm": null
}

Every slot is a start a qualified tech can make, drive included; on a 15-minute grid unless you send stepMinutes: 1, 5, 15 (default), 30 or 60 local minutes (60: whole hours only; 1: any minute, such as 08:07; anything else is 422). The same goes for POST /v1/office/first-available. A start on any grid holds and books as given. timezone is the zone of the address (local is its wall clock): a Baltimore address answers America/New_York, a Phoenix one America/Phoenix (no daylight saving), whatever the company's own zone. A branch whose area holds the address gives its zone; if only techs on another clock cover the address (a Louisville tech serving Evansville), it is theirs. Store start (the instant); show local with timezone. outsideArea: true means no tech's area covers the address (see Time zones and service radius). leadMinutes (default 60) sets how soon from now. "earliestDay": "next_business_day" offers nothing before the next Monday-to-Friday day after today in that zone (Friday, Saturday and Sunday give Monday; public holidays aren't known, so block them as time off); with leadMinutes too, the later wins. Send the same earliestDay (and leadMinutes, if you use one) to POST /v1/office/holds, which then refuses an earlier start with 409 too_soon.

#Booking windows and cutoffs

Rule Where it's set Who it binds
Earliest booking: the next business day earliestDay: "next_business_day" on POST /v1/office/availability and POST /v1/office/holds (per request; leadMinutes alone can't say "next business day") Whoever sends it. Office keys and platforms on the provider's grant are not limited unless they send it
Customer reschedule / cancel cutoff The company's cancelCutoffHours (default 24; PATCH /v1/office/settings, shown in GET /v1/office/me) The homeowner's manage link (409 too_late inside it)
Client reschedule / cancel cutoff The same cancelCutoffHours A client company taking back a scheduled request (POST /v1/connect/requests/{id}/cancel, 409 too_late inside it; a platform filing as the client included). Clients have no reschedule endpoint: they cancel and file again
Office and platform on the provider's grant none Move and cancel at any time (POST /v1/office/jobs/{id}/move, .../cancel)

So for your platform, which books and changes jobs on the provider's grant, CrewLink enforces no cutoff: your platform applies cancelCutoffHours (read it from GET /v1/office/me) to its own clients' and homeowners' changes, and sends earliestDay: "next_business_day" when it asks for times and holds. outsideArea: true means no one covers that address: show no times (the booking page files an out-of-area request instead). Your platform files the same "request a visit" with its key on POST /v1/office/requests: the booking page's body plus clientCompanyId (one of the provider's client companies, so the installer sees it in its own requests) and your externalRef (409 duplicate_external_ref when it is taken, with the jobId). The anonymous POST /v1/public/{slug}/requests can't name a client.

#3. Book it

Either hold first (recommended when your client takes a while to confirm), then book the hold:

bash
curl -s -X POST $CREWLINK/v1/office/holds "${H[@]}" \
  -d '{"serviceId": 41, "lat": 41.955, "lng": -87.675, "start": "2026-11-03T14:30:00Z", "earliestDay": "next_business_day", "notify": false}'
# {"holdId": 1042, "holdSecret": "vY3k...", "start": "2026-11-03T14:30:00.000Z", "local": "2026-11-03T08:30", "timezone": "America/Chicago", "expiresAt": "2026-10-12T15:35:00.000Z", "notify": false}

curl -s -X POST $CREWLINK/v1/office/jobs "${H[@]}" -H 'idempotency-key: acme-appt-88213-book' \
  -d '{
    "holdId": 1042, "holdSecret": "vY3k...",
    "customer": { "name": "Pat Quinn", "phone": "+1 312 555 0177", "email": "pat@example.com" },
    "address": { "line1": "1801 W Irving Park Rd", "line2": "Unit 2", "city": "Chicago", "region": "IL", "postcode": "60613", "country": "US" },
    "notes": "Gate code 4411. Two-story, south-facing roof.",
    "externalRef": "appt_88213"
  }'

or book a time in one call (serviceId, start or local (the wall clock at the address, e.g. "2026-11-03T08:30"), and lat/lng or an address with them):

bash
curl -s -X POST $CREWLINK/v1/office/jobs "${H[@]}" -H 'idempotency-key: acme-appt-88213-book' \
  -d '{
    "serviceId": 41, "start": "2026-11-03T14:30:00Z",
    "customer": { "name": "Pat Quinn", "phone": "+1 312 555 0177" },
    "address": { "line1": "1801 W Irving Park Rd", "city": "Chicago", "region": "IL", "postcode": "60613", "country": "US", "lat": 41.955, "lng": -87.675 },
    "externalRef": "appt_88213"
  }'

201

json
{
  "jobId": 1042,
  "start": "2026-11-03T14:30:00.000Z",
  "local": "2026-11-03T08:30",
  "timezone": "America/Chicago",
  "techs": [{ "id": 3, "name": "Maria G." }],
  "manageToken": "kQ1w...",
  "externalRef": "appt_88213",
  "externalSource": "acme",
  "externalRefs": { "acme": "appt_88213" }
}
  • The homeowner gets CrewLink's confirmation text (company name as the sender brand), a reminder 24 hours before, and a manage link, unless the job's homeowner texts are off (Homeowner messaging: notify: false, or a grant with homeownerMessaging: "off", as your platform's are). Then the homeowner hears nothing from CrewLink about this job, ever.
  • techs names are first name and last initial for a platform key, as everywhere your platform sees techs. manageToken is the homeowner's manage-link token (they also get it by text); you don't need to store it.
  • 409 slot_taken comes with a reason (on_call_reserve, outside_hours, time_off, no_skills, doesnt_fit) and a message you can show. 409 in_the_past, 404 (unknown service, or hold not found / wrong secret), 410 hold_expired, 409 duplicate_external_ref (with jobId).
  • booking.created fires with source: "platform".

#4. Attach the proposal

Upload URL (bytes go straight to storage):

bash
curl -s -X POST $CREWLINK/v1/office/jobs/1042/attachments "${H[@]}" \
  -d '{"kind": "proposal", "name": "Proposal - Quinn.pdf", "contentType": "application/pdf", "bytes": 482113}'
# {"attachment": {"id": "c0f7...", "status": "pending", ...}, "uploadUrl": "https://...", "expiresAt": "..."}
curl -s -X PUT --data-binary @proposal.pdf -H 'content-type: application/pdf' "$UPLOAD_URL"
curl -s -X POST $CREWLINK/v1/office/jobs/1042/attachments/c0f7.../uploaded "${H[@]}"

or one call through the API (up to 20 MB):

bash
curl -s -X POST "$CREWLINK/v1/office/jobs/1042/attachments/upload" \
  -H "authorization: Bearer $PK" -H "x-crewlink-company: $CO" \
  -F kind=proposal -F file=@proposal.pdf

The tech sees it in the field app. attachment.uploaded fires. See Files on a job.

#5. Follow the job

Webhooks tell you as it happens (below): job.en_route (with an ETA), job.arrived, job.completed, booking.rescheduled, booking.cancelled, job.needs_attention, attachment.uploaded. For the full picture at any time:

bash
curl -s $CREWLINK/v1/office/jobs/1042 "${H[@]}"

#6. Reschedule

Ask for times as if the job weren't there, then move it:

bash
curl -s -X POST $CREWLINK/v1/office/availability "${H[@]}" \
  -d '{"serviceId": 41, "lat": 41.955, "lng": -87.675, "from": "2026-11-04", "days": 3, "excludeJobId": 1042}'
curl -s -X POST $CREWLINK/v1/office/jobs/1042/move "${H[@]}" -H 'idempotency-key: acme-appt-88213-move-2' \
  -d '{"start": "2026-11-05T15:00:00Z"}'

The homeowner is texted the new time (unless the job's homeowner texts are off; notify: false on the move turns them off for good); booking.rescheduled fires (from is the old local time, techs who goes now). Every move fires its webhook, notify: false included: notify only controls homeowner texts (what each path sends). History moves (a past time) and unassigns reach only subscriptions with historyEvents: true.

Double-booking and past times on any job. Your key acts as the company's owner, so with write it may send allowOverlap: true (with techIds) and allowPast: true on any job, not only imports. allowOverlap books the techs even though they are already on another visit then (time off, skills, the team, working hours and the on-call reserve are still checked); the answer adds doubleBooked and the job is flagged needs attention Double-booked with #<conflictJobId> (no job.needs_attention with notify: false). allowPast writes a past start as history (booked as given, overdue until completed, no homeowner text), answering history: true. The company's dispatchers and field managers get 403 owner_only for these. See office.md.

Unassigning. techIds: [] (and no start/local) takes everyone off a booked job: it keeps its time and goes back to the intake queue as unscheduled with intakeReason: "Unassigned", its visits are removed, the homeowner is not texted, and booking.rescheduled fires with techs: [], status: "unscheduled", unassigned: true, from and requested (the time it keeps). Place it again with a move and techIds (office.md).

bash
curl -s -X POST $CREWLINK/v1/office/jobs/1042/move "${H[@]}" -H 'idempotency-key: acme-appt-88213-unassign' -d '{"techIds": []}'
# {"jobId": 1042, "status": "unscheduled", "start": "2026-11-05T15:00:00.000Z", "local": "2026-11-05T09:00", "timezone": "America/Chicago", "techs": [], "unassigned": true}

An imported job at its old commitment. A job you imported (source: "import") that went to the intake queue because its tech had no hours then (a tech with no hours set, a Saturday, an evening) can be placed at the time the old CrewLink confirmed: move it with techIds. As for an import with techIds on POST /v1/office/jobs, the tech goes even outside their hours (allowOutsideHours is implied; false turns it off), the job goes onto the tech's clock, and the answer says outsideHours: true. Their hours are not changed. A real overlap with another visit of theirs is 409 tech_busy with conflictJobId; time off and missing skills are still 409 slot_taken. It works for a booked import too (office.md).

An import in the past. A queued or unscheduled import the old CrewLink had booked at a time that has passed (September, say) is moved there with techIds: for imports allowPast is implied (false turns it off), so it is not 409 in_the_past. It is booked as history, unchecked, with real visits, and reads overdue until you mark it done (POST /v1/office/jobs/{id}/complete). No homeowner text or reminder; booking.created (from the queue) or booking.rescheduled always fires with the techs and history: true, notify: false included. The answer adds "history": true (office.md). Other jobs get 409 in_the_past unless you send allowPast: true (above).

bash
curl -s -X POST $CREWLINK/v1/office/jobs/4570/move "${H[@]}" -H 'idempotency-key: acme-import-4570-past' \
  -d '{"local": "2026-09-17T09:00", "techIds": [81], "notify": false}'
# {"jobId": 4570, "start": "2026-09-17T14:00:00.000Z", "local": "2026-09-17T09:00", "timezone": "America/Chicago", "techs": [{"id": 81, "name": "Aim T."}], "history": true}

Keeping a double-booking. If the old CrewLink had the tech on two jobs at once, send allowOverlap: true with techIds on the move (any job for your key, which acts as the owner; see above). The overlap is booked anyway instead of 409 tech_busy; time off and skills are still checked. The answer adds doubleBooked ([{ personId, conflictJobId }]) and the job is flagged needs attention: Double-booked with #<conflictJobId> (imported from the old app). With notify: false nothing is sent for the flag (no job.needs_attention) and no homeowner text; booking.rescheduled / booking.created still fire as on every move (office.md).

bash
curl -s -X POST $CREWLINK/v1/office/jobs/4568/move "${H[@]}" -H 'idempotency-key: acme-import-4568-overlap' \
  -d '{"local": "2026-10-27T09:00", "techIds": [81], "allowOverlap": true, "notify": false}'
# {"jobId": 4568, "start": "2026-10-27T14:00:00.000Z", "local": "2026-10-27T09:00", "timezone": "America/Chicago", "techs": [{"id": 81, "name": "Aim T."}], "doubleBooked": [{"personId": 81, "conflictJobId": 4502}]}
bash
curl -s -X POST $CREWLINK/v1/office/jobs/4568/move "${H[@]}" -H 'idempotency-key: acme-import-4568-place' \
  -d '{"start": "2026-10-12T19:00:00Z", "techIds": [81]}'
# {"jobId": 4568, "start": "2026-10-12T19:00:00.000Z", "local": "2026-10-12T12:00", "timezone": "America/Phoenix", "techs": [{"id": 81, "name": "Aim T."}], "outsideHours": true}

#7. Cancel

bash
curl -s -X POST $CREWLINK/v1/office/jobs/1042/cancel "${H[@]}" -d '{"reason": "Client postponed"}'

No cutoff for the office side. The homeowner is texted (unless notify: false, or the job's homeowner texts are off); queued reminders are dropped; booking.cancelled fires.

A visit request you filed with POST /v1/office/requests that still waits for the office (status: "waiting") isn't cancelled: decline it with POST /v1/office/requests/{id}/decline (404 from cancel). A declined request releases its external ids like a cancelled job; with notify: false (or homeowner messaging off) the homeowner gets nothing.

An imported job's history. An import that is cancelled or done (for example one filed unscheduled and then cancelled, so it has no visit) can get the time and techs the old CrewLink had, keeping its id and externalRefs. They are set exactly as sent (no availability, hours or overlap checks), nothing is sent to anyone (no text, email, tech notification or webhook, deliberately: it is the one office path with no webhook; the timeline gets history_corrected and updatedAt moves, so GET /v1/office/jobs?updatedSince= picks it up), and the status stays. A cancelled job stays off the schedule like any cancelled job: it never blocks a booking, never shows on dispatch and never counts in KPIs or pay; job reads show its start, end and techs. end defaults to start plus the service's booked length. Anything else is 422 not_history (office.md).

bash
curl -s -X POST $CREWLINK/v1/office/jobs/4571/history "${H[@]}" -H 'idempotency-key: acme-import-4571-history' \
  -d '{"start": "2026-08-03T14:00:00Z", "techIds": [81]}'
# {"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 R."}]}

There is no DELETE for jobs.

#8. Complete from your platform (when the tech didn't use the field app)

bash
curl -s -X POST $CREWLINK/v1/office/jobs/1042/complete "${H[@]}" \
  -d '{"at": "2026-11-03T16:10:00Z", "notes": "Survey captured in your platform app", "silent": false}'
json
{ "jobId": 1042, "stage": "done", "doneAt": "2026-11-03T16:10:00.000Z", "silent": false, "minutesOnSite": null }
silent: false (default) silent: true
Job moves to done, doneAt set, timeline entry, queued reminders dropped yes yes
Plan vs actual and KPIs count it yes yes
Review-link text to the homeowner yes no
CrewConnect payout scheduled (when another company sent the job) yes no
job.completed webhook (source: "api") yes, silent: false yes, silent: true

at defaults to now and may be up to 30 days back, or back to the job's own day when it started earlier (422 invalid_at otherwise). 409 already when it is done; 409 not_booked for a job that isn't booked. A tech who finishes in the field app sends job.completed with source: "field_app" instead; don't complete it twice.

#9. Keep in sync

bash
# Everything that changed since the last sync, oldest change first, 100 at a time
curl -s "$CREWLINK/v1/office/jobs?updatedSince=2026-11-03T00:00:00Z&limit=100" "${H[@]}"
# next page
curl -s "$CREWLINK/v1/office/jobs?updatedSince=2026-11-03T00:00:00Z&limit=100&cursor=MjAyNi0xMS0wM1QxNDozMDowMC4wMDBafDEwNDI" "${H[@]}"

Store the largest updatedAt you processed and use it as the next updatedSince (it is inclusive, so you may see the last job again; that's fine). Other filters: status (comma list), from/to (local dates of the start), externalSource, externalRef. Webhooks plus a periodic sync (every 10 to 15 minutes) is the robust combination.

#Keeping techs in step

Techs set their availability in your platform, and your platform feeds it to CrewLink, which plans with it. These calls act on the company the techs belong to (the provider whose crew does the surveys); it must have granted your platform write (read for the lookups). The whole contract for owning availability (every shape, duplicates, who else may edit, webhooks back and echoes) is in Owning technician availability from your system.

#Find or add the tech

bash
# By their email (theirs, or the one they sign in with), or by your id
curl -s "$CREWLINK/v1/office/team?email=rosa@example.com" "${H[@]}"
curl -s "$CREWLINK/v1/office/team?externalSource=acme&externalRef=tech_42" "${H[@]}"

# Add one (hours are minutes after local midnight, Monday first)
curl -s -X POST $CREWLINK/v1/office/team "${H[@]}" -d '{
  "name": "Rosa Delgado", "homeLat": 41.96, "homeLng": -87.68,
  "hours": [[480,1020],[480,1020],[480,1020],[480,1020],[480,1020],null,null],
  "email": "rosa@example.com", "phone": "+1 312 555 0142",
  "externalRef": "tech_42"
}'
  • No default schedule. hours (or availability) is optional: someone added without either has no working hours (every day off), and a tech with no hours is never offered until hours are set (PUT /v1/office/team/{id}/availability, below). Add the tech, then send their week.
  • People carry externalRefs like jobs do: one id per source, each (source, id) unique in the company (409 duplicate_external_ref with personId). The shorthand externalRef uses source acme.
  • PATCH /v1/office/team/{id} { "externalRefs": { "crewlink-v1": "P-7", "acme": null } } adds or replaces ids; null removes a source.
  • Your platform sees names as first name and last initial ("Rosa D."), here and everywhere.
  • email and phone are the tech's own (stored on the person; email lower case, null on PATCH removes it) and come back on every team read. ?email= matches that email or the email of the tech's CrewLink user (POST /v1/office/users with their personId), case-insensitively. The company's owner can then invite every tech with an email to the field app at once (POST /v1/office/team/invite-all, owners only, not platform keys).

#Time zones and service radius

Your platform's techs have no branch and work from home across the US. For them:

  • Zone: a tech's timezone is their own when set ("timezone": "America/New_York" on POST or PATCH /v1/office/team; null on PATCH clears it), else their branch's, else the zone of their home (from homeLat/homeLng, an offline map), else the company's. Team reads show it with ownTimezone (what was set). Zones with the same clock are one: a Louisville or Indianapolis tech reads America/New_York. When a tech's zone changes (their timezone, a move to another zone, another branch), their booked jobs and time off keep their instants and are re-expressed on the new clock (rezoned in the PATCH answer), and person.updated says the new timezone.
  • Radius: a tech without a branch is offered work within maxTravelMiles of home, else the company's defaultTechRadiusMiles (PATCH /v1/office/settings, owner; shown in GET /v1/office/me company), else 40 km; on a base day, around the base. Team reads show it as serviceRadiusMiles. A branch's techs keep the branch's area. acme-crew has defaultTechRadiusMiles: 150.
  • An address answers on its own clock (above); outsideArea is true when no tech's area covers it on any day asked about (a tech's base covers its own radius around the base on those days).
  • Jobs booked before zones per person carried the company's zone (America/Chicago) even for a survey in Pennsylvania or Arizona. On go.crewlink.ai they are re-labelled once to the zone they'd be booked in now (the address's, or the zone of the techs on them), keeping every instant: start, end and each visit's UTC are unchanged; timezone, local and endLocal change, and so does updatedAt (pick them up with updatedSince). No webhook is sent for it.
  • Importing old commitments: POST /v1/office/jobs with source: "import" and techIds books a future visit the old app had confirmed even outside the tech's hours or on a day they don't work (allowOutsideHours, the default for imports with techIds); their hours are not changed and the job reads outsideHours: true. A tech already on another visit then is 409 tech_busy (personId, conflictJobId); send allowQueue: true to have it queued instead. See office.md.
bash
curl -s -X POST $CREWLINK/v1/office/team "${H[@]}" -d '{
  "name": "Bea Lane", "homeLat": 39.29, "homeLng": -76.61,
  "hours": [[480,1020],[480,1020],[480,1020],[480,1020],[480,1020],null,null], "externalRef": "tech_77"
}'
# -> { ..., "timezone": "America/New_York", "ownTimezone": null, "serviceRadiusMiles": 150, ... }
curl -s -X POST $CREWLINK/v1/office/availability "${H[@]}" -d '{"serviceId": 41, "lat": 39.29, "lng": -76.61, "from": "2026-10-13", "days": 1}'
# -> { "timezone": "America/New_York", "slots": [{ "start": "2026-10-13T12:15:00.000Z", "local": "2026-10-13T08:15" }, ...], "outsideArea": false, ... }

#Replace the week

bash
curl -s -X PUT $CREWLINK/v1/office/team/57/availability "${H[@]}" -H 'idempotency-key: acme-avail-tech_42-v12' -d '{
  "timezone": "America/Chicago",
  "hours": [[[480,720],[780,1020]], [480,1020], [480,1020], [480,1020], [480,960], null, []]
}'
json
{ "personId": 57, "timezone": "America/Chicago", "availability": [[[480,720],[780,1020]],[[480,1020]],[[480,1020]],[[480,1020]],[[480,960]],[],[]], "hours": [[480,1020],[480,1020],[480,1020],[480,1020],[480,960],null,null], "merged": [], "changed": true, "outsideHours": [] }
  • The whole week, Monday first. Each day: off (null or []), one window [start, end], or several [[start, end], ...], in minutes after local midnight in the tech's zone (timezone on GET /v1/office/team: their own, else their branch's, else their home's). A Baltimore tech's [480, 1020] is 8:00 to 5:00 Eastern.
  • Idempotent: the same week again changes nothing (changed: false) and sends no event.
  • Several windows a day are kept as sent (up to 8 a day): availability is every window, and nothing is offered in the gaps. hours is each day's envelope (first start to last end), for code that reads one window a day. merged is always [] (kept for integrations that read it). The answer is always what is stored.
  • timezone is optional; if sent it must keep the same clock as the tech's zone (422 timezone_mismatch). To put a tech on another zone, PATCH /v1/office/team/{id} { "timezone": "America/Denver" } first.
  • Jobs already booked stay put; outsideHours lists booked job ids now outside the hours, for the office to move.
  • Sends availability.updated and person.updated.

#Time off

bash
curl -s -X POST $CREWLINK/v1/office/blocks "${H[@]}" -d '{
  "personId": 57, "start": "2026-12-01T14:00:00Z", "end": "2026-12-01T23:00:00Z", "reason": "PTO", "externalRef": "pto-9"
}'
# sending it again: 409 duplicate_external_ref with "blockId"
curl -s "$CREWLINK/v1/office/blocks?externalSource=acme&externalRef=pto-9" "${H[@]}"
curl -s -X DELETE $CREWLINK/v1/office/blocks/311 "${H[@]}"

Blocks take externalRefs (or the shorthand) so a sync that resends one gets 409 duplicate_external_ref with the existing blockId instead of a second block. GET /v1/office/blocks?externalSource=&externalRef= finds it on any date. Nothing new is offered inside a block; jobs already booked in it come back as conflicts.

#Bases (lodging, a temporary address)

bash
curl -s -X POST $CREWLINK/v1/office/team/57/bases "${H[@]}" -d '{
  "from": "2026-12-07", "to": "2026-12-09", "lat": 41.88, "lng": -87.63,
  "address": { "line1": "111 W Hotel St", "city": "Chicago", "region": "IL", "postcode": "60601", "country": "US" },
  "reason": "Hotel", "externalRefs": { "acme": "stay-1" }
}'
json
{ "id": "e3b0c442-...", "personId": 57, "from": "2026-12-07", "to": "2026-12-09", "lat": 41.88, "lng": -87.63, "address": "111 W Hotel St, Chicago, IL 60601, US", "addressParts": { "line1": "111 W Hotel St", "city": "Chicago", "region": "IL", "postcode": "60601", "country": "US" }, "reason": "Hotel", "externalRefs": { "acme": "stay-1" }, "tripId": null, "createdAt": "..." }
  • From from to to (local dates, inclusive, up to 366 days) the tech sets off from and returns to the base instead of home: open times and routing use it.
  • 409 trip_conflict (with tripId) when a booked CrewLink trip covers any of those days: the trip's lodging already is the base. 409 base_conflict (with baseId) when another base does.
  • A trip booked in CrewLink writes its own base (tripId set, reason: "trip"); it can't be removed on its own (409 trip_base).
  • GET /v1/office/team/{id}/bases?from=&to= lists them (default: today and the 90 days after); DELETE /v1/office/team/{id}/bases/{baseId} removes one.
  • Sends person.base.updated.

#Test clients and test techs

A test client's jobs only ever go to test techs, and no other job ever goes to a test tech (office.md). Add a tech as a test tech in one call with POST /v1/office/team { ..., "test": true }, or mark one with PATCH /v1/office/team/{id} { "test": true } (your key may); the company's owner marks a client company test (PATCH /v1/office/clients/{id} { "test": true }; platform keys never write client companies). Team reads, the schedule and the dispatch board carry test.

Send the client with every question about a client's job, so the right techs are offered: clientCompanyId on POST /v1/office/availability, POST /v1/office/holds (kept on the hold) and POST /v1/office/jobs. Without it, the job is treated as nobody's test work: test techs are never offered. A hold made without it can't be booked for a test client (409 slot_taken, reason: "not_on_team"): hold again with clientCompanyId. Test techs are left out of KPIs, Wins and pay reports unless includeTest=true.

#Events about techs

They go to the platform-wide subscription like every other event.

json
{ "event": "person.updated", "companyId": "...", "data": { "personId": 57, "displayName": "Rosa D.", "active": true, "kind": "employee", "branchId": null, "timezone": "America/Chicago", "home": { "lat": 41.96, "lng": -87.68 }, "base": null, "hours": [[480,1020],[480,1020],[480,1020],[480,1020],[480,960],null,null], "externalRefs": { "acme": "tech_42" } } }
Event When data
person.updated A tech is added or changed (POST/PATCH /v1/office/team or the weekly hours, from the office app, any key or your platform) personId, displayName, active, kind, branchId, timezone, home, base (the base in force today, or null), availability, hours, maxTravelMiles, externalRefs, source, platform, forced
availability.updated Their weekly hours changed personId, externalRefs, availability, hours, timezone, source, platform, forced
person.base.updated A base was added or removed (including a trip's) personId, action (created or deleted), base (as above), externalRefs (the person's), timezone, source, platform, forced

source, platform and forced say who made the change, so you can skip your own echoes: see Webhooks back, and ignoring your own echoes.

#Owning technician availability from your system

Your platform is where acme-crew's techs set their availability. CrewLink fully supports an integration owning technician availability: you are the source of truth for each tech's weekly hours, time off, home, travel radius and temporary bases; CrewLink stores exactly what you send, plans and books with it, and tells you about every change so the two systems stay identical. On go.crewlink.ai, acme-crew is marked as managed by acme (below), so nobody else can change these fields there.

Everything in this section needs the company's grant with write (read for the reads). Send X-CrewLink-Company as usual.

What Call Your id for it
The tech POST /v1/office/team, PATCH /v1/office/team/{id}, GET /v1/office/team/{id}, GET /v1/office/team?externalSource=acme&externalRef=… externalRefs on the person
Weekly hours (windows per day, zone) PUT /v1/office/team/{id}/availability the person's (the call is idempotent)
Time off / blocks POST /v1/office/blocks, GET /v1/office/blocks, DELETE /v1/office/blocks/{id} externalRefs on the block
Home (start and end of the day) PATCH /v1/office/team/{id} homeLat, homeLng, address
Travel radius PATCH /v1/office/team/{id} maxTravelMiles
Zone PATCH /v1/office/team/{id} timezone
Trips and base overrides (lodging, a temporary address) POST /v1/office/team/{id}/bases, GET …/bases, DELETE …/bases/{baseId} externalRefs on the base
Who owns availability GET / PUT /v1/office/availability-management

#The tech

Find the tech by your id first, then add them only if they aren't there (a second POST with the same id is refused, never doubled):

bash
curl -s "$CREWLINK/v1/office/team?externalSource=acme&externalRef=tech_42" "${H[@]}"
# -> { "people": [] }  (not there yet)
curl -s -X POST $CREWLINK/v1/office/team "${H[@]}" -d '{
  "name": "Rosa Delgado", "homeLat": 41.96, "homeLng": -87.68,
  "address": { "line1": "4499 N Melvina Ave", "city": "Chicago", "region": "IL", "postalCode": "60630", "country": "US" },
  "timezone": "America/Chicago", "maxTravelMiles": 120,
  "availability": [[[480,720],[780,1020]], [[480,1020]], [[480,1020]], [[480,1020]], [[480,960]], [], []],
  "email": "rosa@example.com", "externalRef": "tech_42"
}'
# -> 201 <person>
# The same externalRef again: 409 { "error": "duplicate_external_ref", "message": "...", "personId": 57 }

GET /v1/office/team/{id} reads one person: the same object as an item of GET /v1/office/team (the company's people only, 404 for anyone else, including another company's; names as your grant says: "First L." unless the grant is full). A person object carries, among the other fields:

json
{
  "id": 57, "name": "Rosa D.", "active": true, "branchId": null,
  "home": { "lat": 41.96, "lng": -87.68 },
  "address": { "line1": "4499 N Melvina Ave", "city": "Chicago", "region": "IL", "postalCode": "60630", "country": "US" },
  "timezone": "America/Chicago", "ownTimezone": "America/Chicago",
  "availability": [[[480,720],[780,1020]], [[480,1020]], [[480,1020]], [[480,1020]], [[480,960]], [], []],
  "hours": [[480,1020], [480,1020], [480,1020], [480,1020], [480,960], null, null],
  "maxTravelMiles": 120, "serviceRadiusMiles": 120,
  "externalRefs": { "acme": "tech_42" },
  "availabilityManagedBy": "acme", "availabilityManagedByName": "Acme", "ownAvailabilityManagedBy": null
}
  • availability is the week exactly as stored and in the same shape PUT …/availability takes: Monday first, each day a list of [start, end] windows in minutes after local midnight on the tech's clock (timezone), [] off. Read and compare this one.
  • hours is kept for older code that reads one window a day: each day's envelope (first start to last end), null off. Never write it back as the week: with a lunch gap it would fill the gap.
  • availabilityManagedBy is who owns this person's availability (acme), null when it's managed in CrewLink.

#Weekly hours

bash
curl -s -X PUT $CREWLINK/v1/office/team/57/availability "${H[@]}" -H 'idempotency-key: acme-avail-tech_42-v12' -d '{
  "timezone": "America/Chicago",
  "hours": [[[480,720],[780,1020]], [480,1020], [480,1020], [480,1020], [480,960], null, []]
}'

Request: hours is 7 days, Monday first; each day null or [] (off), one window [start, end], or up to 8 windows [[start, end], ...] (not overlapping), minutes 0 to 1440 after local midnight. timezone is optional; if sent it must keep the tech's clock (422 timezone_mismatch; change the zone with PATCH … { "timezone" } first).

Response 200:

json
{
  "personId": 57,
  "timezone": "America/Chicago",
  "availability": [[[480,720],[780,1020]], [[480,1020]], [[480,1020]], [[480,1020]], [[480,960]], [], []],
  "hours": [[480,1020], [480,1020], [480,1020], [480,1020], [480,960], null, null],
  "merged": [],
  "changed": true,
  "outsideHours": []
}
Field Notes
availability The stored week in the request's shape (every day normalised to a list of windows, sorted). Compare it with what you sent
hours Each day's envelope, null off (compatibility; as on team reads)
merged Always [] (kept for integrations that read it)
changed false when the week was already this: nothing was written and no webhook was sent
outsideHours Booked job ids (today on) now outside the hours. They stay booked; the office moves them

Idempotent by content: sending the same week again is changed: false. You may also send an Idempotency-Key header (Idempotency and retries).

#Time off and blocks

bash
curl -s -X POST $CREWLINK/v1/office/blocks "${H[@]}" -d '{
  "personId": 57, "start": "2026-12-01T14:00:00Z", "end": "2026-12-01T23:00:00Z", "reason": "PTO", "externalRef": "pto-9"
}'
json
{ "id": 311, "personId": 57, "start": "2026-12-01T08:00", "end": "2026-12-01T17:00", "timezone": "America/Chicago", "reason": "PTO", "conflicts": [], "externalRefs": { "acme": "pto-9" } }
  • start and end are instants (ISO with offset); the answer gives them on the tech's clock. Up to 60 days per block; longer leave is several blocks.
  • Duplicates: sending a block with an id that is already on a block is 409 duplicate_external_ref with "blockId": 311 and creates nothing. Treat it as success and keep blockId.
  • Changing a block: there is no PATCH. Delete it and add the new one (with the same externalRef once the old one is gone).
  • conflicts lists booked jobs inside the block; they stay booked for the office to move.
  • Find yours on any date with GET /v1/office/blocks?externalSource=acme&externalRef=pto-9. Remove with DELETE /v1/office/blocks/{id} ({ "id": 311, "deleted": true }).
  • Reading a range: GET /v1/office/blocks?from=YYYY-MM-DD&to=YYYY-MM-DD[&personId=57] returns every block overlapping those local dates (earliest first, then by id), 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's no cursor: each call returns everything in its range, so page by date windows (from = the day after the previous to) and de-duplicate by id (a block spanning two windows comes back in both).
  • Blocks with source google or outlook are busy time from the tech's linked calendar. They aren't yours: don't delete them (they come back at the next sync) and don't copy them back as time off.

#Home and travel radius

bash
curl -s -X PATCH $CREWLINK/v1/office/team/57 "${H[@]}" -d '{
  "homeLat": 41.88, "homeLng": -87.63,
  "address": { "line1": "111 W Lake St", "city": "Chicago", "region": "IL", "postalCode": "60601", "country": "US" },
  "maxTravelMiles": 90
}'
# -> { "id": 57, "outsideHours": [], "externalRefs": { "acme": "tech_42" }, "timezone": "America/Chicago", "rezoned": { "jobs": 0, "blocks": 0 } }
  • homeLat/homeLng go together; the day starts and ends there from now on. Send address with them.
  • maxTravelMiles (1 to 3000, null removes it): the tech is never offered work farther than this from home (or that day's base). Without it, the company's defaultTechRadiusMiles applies (150 for acme-crew). Team reads show the result as serviceRadiusMiles.
  • timezone (IANA, null follows home) sets the clock their hours are read on. A change re-expresses their booked jobs and time off on the new clock keeping the instants (rezoned).

#Trips and base overrides

bash
curl -s -X POST $CREWLINK/v1/office/team/57/bases "${H[@]}" -d '{
  "from": "2026-12-07", "to": "2026-12-09", "lat": 41.88, "lng": -87.63,
  "address": { "line1": "111 W Hotel St", "city": "Chicago", "region": "IL", "postcode": "60601", "country": "US" },
  "reason": "Hotel", "externalRefs": { "acme": "stay-1" }
}'
json
{ "id": "e3b0c442-...", "personId": 57, "from": "2026-12-07", "to": "2026-12-09", "lat": 41.88, "lng": -87.63, "address": "111 W Hotel St, Chicago, IL 60601, US", "addressParts": { "line1": "111 W Hotel St", "city": "Chicago", "region": "IL", "postcode": "60601", "country": "US" }, "reason": "Hotel", "externalRefs": { "acme": "stay-1" }, "tripId": null, "createdAt": "2026-10-12T15:30:00.000Z" }
  • From from to to (local dates, inclusive, up to 366 days) the tech sets off from and returns to the base instead of home; their radius is measured from it on those days.
  • Duplicates: an externalRefs id already on a base of this company is 409 duplicate_external_ref with "baseId"; nothing is created.
  • 409 base_conflict (baseId): another base covers some of those days; 409 trip_conflict (tripId): a trip booked in CrewLink covers them (its lodging is already the base). Changing a base is delete and add: DELETE /v1/office/team/{id}/bases/{baseId} (409 trip_base for a trip's own base).
  • GET /v1/office/team/{id}/bases?from=&to= lists them (default today and the 90 days after).

#Availability managed by an integration

A company can say an integration owns its technician availability: availabilityManagedBy is null (managed in CrewLink, the default) or a platform slug ("acme"). acme-crew is set to acme. While it is set:

  • Your key writes as always. Nothing changes for your platform's calls.

  • Everyone else is refused with 409 managed_by_integration when they change weekly hours, time off, home, zone, travel radius or bases of that company's people: office users (owners, field managers, dispatchers), the company's API key, other platforms and the tech in the field app. The calls: PUT /v1/office/team/{id}/availability; PATCH /v1/office/team/{id} with hours, availability, homeLat/homeLng, address, timezone or maxTravelMiles (other fields, such as phone or skills, still work); POST and DELETE /v1/office/blocks; POST and DELETE /v1/office/team/{id}/bases; PATCH /v1/tech/profile.

    json
    { "error": "managed_by_integration", "message": "Hours for this team are managed in Acme. Change them in Acme.", "managedBy": "acme", "managedByName": "Acme" }
  • The CrewLink web app shows those fields read-only on Team and in each person's profile, with "Hours, time off and home are managed in Acme. Change them there."; the tech's own screen shows the same note instead of the edit button.

  • Emergencies: an owner (a signed-in owner or the company API key, never a platform) may override one request by adding "force": true to the body (?force=true on the DELETEs). It goes through, is recorded (GET /v1/office/availability-management overrides) and its webhooks say "forced": true with source office or api. Anyone else sending force gets 403 forbidden. When you see forced: true, the change happened in CrewLink: bring it into your platform or send your value again.

  • Calendar sync keeps working: busy time from a tech's linked Google or Outlook calendar is still imported, and those blocks can still be removed.

  • Booking is unchanged: the engine reads the same hours, blocks, homes and bases whoever wrote them. Adding people (POST /v1/office/team) is not restricted.

  • One person can differ from the company: PATCH /v1/office/team/{id} { "availabilityManagedBy": "crewlink" } (owner) manages that person in CrewLink; "acme" puts one person under your platform in a company that isn't; null follows the company. Team reads show the result (availabilityManagedBy) and the person's own setting (ownAvailabilityManagedBy). Your key may set its own slug or null, and can't undo an owner's crewlink or take over a person whose availability another integration owns (theirs, or the company's): 409 managed_by_integration; only an owner can move them.

Read or change it:

bash
curl -s $CREWLINK/v1/office/availability-management "${H[@]}"
# -> { "availabilityManagedBy": "acme", "availabilityManagedByName": "Acme", "people": [], "overrides": [] }
curl -s -X PUT $CREWLINK/v1/office/availability-management "${H[@]}" -d '{ "managedBy": "acme" }'   # claim it (your own slug)
curl -s -X PUT $CREWLINK/v1/office/availability-management "${H[@]}" -d '{ "managedBy": null }'     # hand it back to CrewLink

A platform key may claim it for itself or release it when it is the owner (409 managed_by_integration when another integration owns it); a company owner may set any platform that has a live grant (404 otherwise) or null. overrides (owners only; [] for a platform key) lists the latest 50 audit rows: { "at", "action": "availability_force" | "availability_managed_by", "actor": "user:<id>" | "api_key" | "admin:<id>" | "platform:<slug>", "personId", "method", "path", "detail" }. GET /v1/office/me also shows availabilityManagedBy and availabilityManagedByName in company.

#Webhooks back, and ignoring your own echoes

Every change to a tech or their time sends a webhook to your platform-wide subscription, including the changes you make. Each of these carries who made it:

Field Values
source platform (a platform key; see platform), office (a signed-in office user), api (the company's API key), tech (the field app), admin (CrewLink staff acting as the owner), system (CrewLink itself: a CLI step, a default week)
platform The platform's slug when source is platform ("acme"), else null
forced true when an owner overrode your ownership with force
Event When data
availability.updated Weekly hours changed (changed: true only) personId, externalRefs, availability (windows, as PUT takes them), hours (envelopes), timezone, source, platform, forced
person.updated A person was added or changed (any field, including the week) personId, displayName, active, kind, branchId, timezone, home, base (in force today, or null), availability, hours, maxTravelMiles, externalRefs, source, platform, forced
block.created A block was added id, personId, start, end (the tech's clock), timezone, reason, conflicts, externalRefs, source, platform, forced
block.deleted A block was removed id, personId, externalRefs (the block's), blockSource (office, google, outlook), source, platform, forced
person.base.updated A base was added or removed (a trip booked in CrewLink adds one too) personId, action (created, deleted), base, externalRefs (the person's), timezone, source, platform, forced
json
{ "event": "availability.updated", "createdAt": "2026-10-12T15:30:00.000Z", "companyId": "...", "data": {
  "personId": 57, "externalRefs": { "acme": "tech_42" },
  "availability": [[[480,720],[780,1020]], [[480,1020]], [[480,1020]], [[480,1020]], [[480,960]], [], []],
  "hours": [[480,1020], [480,1020], [480,1020], [480,1020], [480,960], null, null],
  "timezone": "America/Chicago", "source": "platform", "platform": "acme", "forced": false } }
json
{ "event": "block.deleted", "createdAt": "2026-10-12T16:02:00.000Z", "companyId": "...", "data": {
  "id": 311, "personId": 57, "externalRefs": { "acme": "pto-9" }, "blockSource": "office",
  "source": "api", "platform": null, "forced": true } }

Echoes. Ignore an event when data.source == "platform" && data.platform == "acme": it is your own write coming back, and you already have it. Apply anything else (forced: true above all) to your platform, or answer it by sending your platform's value again.

Ordering. Deliveries can arrive out of order and more than once (webhooks.md). Don't apply an event over newer data: treat availability.updated and person.updated as "this person changed", then read the current state with GET /v1/office/team/{id} (or compare the event's createdAt with the last one you applied per person). De-duplicate with the delivery id (id in the body, the CrewLink-Delivery header). Blocks and bases are created and deleted, never edited, so match them by externalRefs or id and apply each once.

A full sync (nightly, or after an outage): for each tech, GET /v1/office/team/{id} (or the list) and compare availability, home, address, timezone and maxTravelMiles with yours, PUT the week and PATCH the rest where they differ (equal weeks are no-ops); GET /v1/office/blocks in 92-day windows and GET …/bases to add what's missing (your ids make re-sends safe) and delete what you no longer have.

#Dispatch board

GET /v1/office/dispatch?date=YYYY-MM-DD[&branch=<branch id>][&client=<clientCompanyId>|none] (C read) returns everything CrewLink's Dispatch page draws for one local day, so your platform's admin app can render the same board: who is working, their stops in order with the planned drive before each, time off, live status and ETAs, the intake queue, risks, counts and map pins. One call per company per refresh. Read-only.

bash
curl -s "$CREWLINK/v1/office/dispatch?date=2026-10-12" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" \
  -H 'If-None-Match: W/"q0bVv0n3..."'
# 304 (no body) when nothing changed since that ETag, else 200 with a new ETag header

#Polling

  • Every 200 carries a weak ETag (and Cache-Control: private, no-cache, Vary: Authorization, X-CrewLink-Company). Keep it per company and query, and send it back as If-None-Match: an unchanged board answers 304 with no body. The tag covers the whole body except generatedAt, so a live ETA that moves by a minute is a change.
  • Poll every 30 to 60 seconds while someone is looking at the board, and stop when nobody is. Every call counts against the platform key's 1,200 a minute across all companies, 304s included: 20 open boards polled every 30 seconds is 40 a minute. For job changes between polls, the webhooks are still the source of truth.

#Query

Param
date Required. The local date (422 for one that doesn't exist, such as 2026-02-30). Each lane is that date on the tech's own clock (tech.timezone): a Baltimore tech's Oct 12 and a Chicago tech's Oct 12 are each their own day
branch A branch id (branches[].id): only that branch's people, and the queue jobs that branch serves. 404 not_found when it isn't one of the company's
client A client company id (e.g. Globex Solar's): only that client's stops and queue jobs; none: jobs with no client. Every working lane stays on the board, and utilization still counts the whole day

#What your platform sees

  • Techs as displayName "First L.", or as they are when the company's grant says peopleNames: "full" (What your platform sees of people). The company's own keys see full names.
  • Customers (homeowners) by name only: no phone on the board (the job detail has it).
  • A tech's location by the grant (the position rule): with peopleNames: "full" (your platform on its own crew) while the tech is on the clock that day (now, and when the position was sent, both between 30 minutes before their first working window or visit and 30 minutes after their last); with "short" only while the tech is working a job and the fix is at most 15 minutes old. Otherwise null; never off the clock.
  • Only the company named in X-CrewLink-Company, and only if it granted read (or write). Any other company is 404.

#The answer

Field
date, timezone The date asked for; the board's zone (the branch's with branch, else the company's). Each lane keeps its own
generatedAt When this was computed (UTC)
live true when some lane has a live status (the date is today for someone)
branches[] { id, name, timezone }: all of the company's, or the one asked for
lanes[] One per active person with working hours that weekday or a visit that day, sorted by name (below)
queue[] The intake queue: jobs with no time yet (queued, waiting out-of-area requests, unscheduled bumped jobs), oldest first, at most 100 (below)
risks[] { kind, jobId, techId, minutesLate, message }: late and tight (live: the tech's next job, today), and tight for a planned leg from today on: two booked jobs of one tech with less time between them than the drive planning allows plus the company's bufferMinutes (minutesLate = minutes short; the message names both jobs; Drive time and the buffer). Then overdue (a stop on the day that ended without being marked done), then aging (a queue job waiting 3 or more days)
counts jobs (on the board), working (lanes), bookedPercent (booked minutes over working minutes, all lanes), driving, ok, tight, late, onJob, idle, overdue, needsAttention, unscheduled (the whole queue)
pins[] One per job on the board: { jobId, lat, lng, state, stage, techIds }

A lane:

Field
tech { id, displayName, timezone, branchId, home: { lat, lng }, hours }. hours is that day's working windows, [[start, end], ...] in minutes after local midnight ([] when they're off but have a visit)
status Live, on the tech's today only (else null): ok (on track: arrives 5+ minutes early), tight (5 early to 10 late), late (more than 10 late), on_job (on site and on track), idle (nothing left today)
doing on_site, driving, before_leaving or done (with status; else null)
fieldStatus Live, the tech's today only (else null): on_site, en_route, idle (on the clock, not driving or on site) or off (off the clock)
currentJobId, nextJobId Live: the job they're on now (on site, or due in its window and not started), and the next one not started; else null
location { lat, lng, at, accuracy, heading, speed } or null (see above). at is when the phone took the fix; accuracy (metres), heading (degrees from true north) and speed (m/s) are null when the phone didn't send them
stops[] The visits in start order (below)
blocks[] Time off and busy time: { start, end, local, localEnd, reason, source }; source is office, google or outlook
utilization { bookedMinutes, hoursMinutes, percent }: every visit that day (whatever client) over the working windows; percent is null with no hours
test true: a test tech (only takes test clients' jobs); show a Test badge

A stop:

Field
jobId, externalRefs CrewLink's id and yours ({ "acme": "proj_123" })
clientCompanyId, client The client company the job is for ({ id, name }), or null
service { id, name, category }
customer { name }, or null
address { line1, line2, city, region, postcode, lat, lng, full }: as sent in parts when the job was booked that way, else read from the one-line address (best effort; full is the line)
start, end Instants (UTC)
local, localEnd Wall clock on the tech's clock, YYYY-MM-DDTHH:MM
status Stored: booked, or held (a hold on the booking page)
state held, scheduled, in_progress (on the way or on site), overdue (ended, not marked done) or completed
jobStatus, stage As on job objects; stage is the field progress (scheduled, en_route, on_site, done)
overdue, needsAttention, attentionReason Flags
homeownerTexts false: this job sends the homeowner nothing
outsideHours true: booked outside the tech's working hours (an import honouring a time the old system confirmed)
test true: the job is for a test client
driveMinutesBefore The planned drive to this stop from the previous one (or home, or a base), whole minutes; null for holds
driveFrom home, base or job
driveSource plan: the day's plan snapshot the techs are working to (taken at 18:00 the evening before and after every change since); predicted: CrewLink's engine prediction for the day as booked now (days further out, or when the snapshot no longer matches)
eta, minutesLate Live, while the tech is on the way to this stop (eta in UTC; negative minutesLate is early); else null. risk has an ETA for every stop not reached yet
risk { level, eta, minutesLate, reason, basis } or null: see risk
enRouteAt, arrivedAt, completedAt The field taps (UTC): On the way, Arrived, Done; null until they happen
plannedOrder, actualOrder 1-based: by booked start among this lane's stops; by arrival among the stops arrived at (null before arrival). Different numbers mean the tech went out of order
actualDriveMinutes The drive to this stop as it happened (On the way to Arrived), whole minutes; null until both were tapped. Compare driveMinutesBefore (planned)

A queue row: { jobId, status, jobStatus, ageDays, createdAt, intakeReason, source, clientCompanyId, client, externalRefs, service, customer, address, timezone, requested, requestedDays, needsAttention, attentionReason, test }. requested is the time asked for ({ start, local }) on a queued or bumped job; requestedDays the days an out-of-area request asked for. Place one with GET /v1/office/intake/{id}/options and POST /v1/office/jobs/{id}/move.

#Example

Trimmed (one lane of four, one stop of three, one queue row of two):

json
{
  "date": "2026-10-12",
  "timezone": "America/Chicago",
  "generatedAt": "2026-10-12T15:30:00.000Z",
  "live": true,
  "branches": [
    { "id": "6f1c0d3e-...", "name": "North", "timezone": "America/Chicago" },
    { "id": "a2b47e19-...", "name": "West", "timezone": "America/Chicago" }
  ],
  "lanes": [
    {
      "tech": { "id": 1, "displayName": "Maria G.", "timezone": "America/Chicago", "branchId": "6f1c0d3e-...", "home": { "lat": 41.93, "lng": -87.7 }, "hours": [[480, 1020]] },
      "status": "ok",
      "doing": "driving",
      "fieldStatus": "en_route",
      "currentJobId": null,
      "nextJobId": 1188,
      "location": { "lat": 41.958, "lng": -87.688, "at": "2026-10-12T15:29:00.000Z", "accuracy": 8, "heading": 90, "speed": 12 },
      "stops": [
        {
          "jobId": 1188,
          "externalRefs": { "acme": "proj_b" },
          "clientCompanyId": "c41e0a77-...",
          "client": { "id": "c41e0a77-...", "name": "Globex Solar" },
          "service": { "id": 1, "name": "Site survey", "category": "residential" },
          "customer": { "name": "Bo Driveway" },
          "address": { "line1": "9 Oak Ave", "line2": null, "city": "Chicago", "region": "IL", "postcode": "60625", "lat": 41.96, "lng": -87.69, "full": "9 Oak Ave, Chicago, IL 60625" },
          "start": "2026-10-12T16:00:00.000Z",
          "end": "2026-10-12T17:00:00.000Z",
          "local": "2026-10-12T11:00",
          "localEnd": "2026-10-12T12:00",
          "status": "booked",
          "state": "in_progress",
          "jobStatus": "en_route",
          "stage": "en_route",
          "overdue": false,
          "needsAttention": false,
          "attentionReason": null,
          "homeownerTexts": false,
          "outsideHours": false,
          "driveMinutesBefore": 6,
          "driveFrom": "job",
          "driveSource": "plan",
          "eta": "2026-10-12T15:31:00.000Z",
          "minutesLate": -29,
          "risk": { "level": "on_time", "eta": "2026-10-12T15:31:00.000Z", "minutesLate": -29, "reason": null, "basis": "live" },
          "enRouteAt": "2026-10-12T15:20:00.000Z",
          "arrivedAt": null,
          "completedAt": null,
          "plannedOrder": 2,
          "actualOrder": null,
          "actualDriveMinutes": null
        }
      ],
      "blocks": [],
      "utilization": { "bookedMinutes": 180, "hoursMinutes": 540, "percent": 33 }
    }
  ],
  "queue": [
    {
      "jobId": 1201, "status": "queued", "jobStatus": "unscheduled", "ageDays": 5, "createdAt": "2026-10-07T15:00:00.000Z",
      "intakeReason": "Nobody free then", "source": "platform", "clientCompanyId": null, "client": null, "externalRefs": { "acme": "proj_q" },
      "service": { "id": 1, "name": "Site survey", "category": "residential" }, "customer": { "name": "Quinn Waiting" },
      "address": { "line1": "1 Lake St", "line2": null, "city": "Chicago", "region": "IL", "postcode": "60601", "lat": 41.95, "lng": -87.68, "full": "1 Lake St, Chicago, IL 60601" },
      "timezone": "America/Chicago", "requested": { "start": "2026-10-09T15:00:00.000Z", "local": "2026-10-09T10:00" }, "requestedDays": null,
      "needsAttention": false, "attentionReason": null
    }
  ],
  "risks": [
    { "kind": "late", "jobId": 1190, "techId": 2, "minutesLate": 63, "message": "James O. is running 63 min late to #1190 (Eve Far), due 10:45." },
    { "kind": "overdue", "jobId": 1189, "techId": 2, "minutesLate": null, "message": "#1189 (Dee Overdue) ended at 10:00 and isn't marked done." },
    { "kind": "aging", "jobId": 1201, "techId": null, "minutesLate": null, "message": "#1201 (Quinn Waiting) has waited 5 days for a time." }
  ],
  "counts": { "jobs": 5, "working": 4, "bookedPercent": 14, "driving": 1, "ok": 1, "tight": 0, "late": 1, "onJob": 0, "idle": 2, "overdue": 1, "needsAttention": 0, "unscheduled": 2 },
  "pins": [
    { "jobId": 1188, "lat": 41.96, "lng": -87.69, "state": "in_progress", "stage": "en_route", "techIds": [1] }
  ]
}

#Live positions and risk

For your platform's own dispatch view (admins; and clients, with names and exact positions stripped on your platform's side). Everything here is also on the dispatch board; this is the light cut of it plus two webhooks. Same company isolation: only the company in X-CrewLink-Company, only with read (or write); any other company is 404.

#Poll: GET /v1/office/dispatch/live

GET /v1/office/dispatch/live[?date=YYYY-MM-DD][&branch=<branch id>][&client=<clientCompanyId>|none] (C read). date defaults to today in the company's zone. branch and client work as on the board: client keeps every working tech but only that client's jobs (none: jobs with no client).

bash
curl -s "$CREWLINK/v1/office/dispatch/live?client=$CLIENT_ID" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" \
  -H 'If-None-Match: W/"Zq1f..."'
# 304 (no body) when nothing changed, else 200 with a new ETag
json
{
  "date": "2026-10-12",
  "timezone": "America/Chicago",
  "generatedAt": "2026-10-12T15:30:00.000Z",
  "live": true,
  "techs": [
    {
      "personId": 1, "displayName": "Maria Gomez", "timezone": "America/Chicago", "branchId": "6f1c0d3e-...", "test": false,
      "status": "en_route", "laneStatus": "ok", "currentJobId": null, "nextJobId": 1188,
      "position": { "lat": 41.958, "lng": -87.688, "accuracy": 8, "heading": 90, "speed": 12, "recordedAt": "2026-10-12T15:29:00.000Z" }
    }
  ],
  "jobs": [
    {
      "jobId": 1188, "personId": 1, "externalRefs": { "acme": "proj_b" }, "clientCompanyId": "c41e0a77-...", "stage": "en_route",
      "start": "2026-10-12T16:00:00.000Z", "end": "2026-10-12T17:00:00.000Z", "local": "2026-10-12T11:00",
      "eta": "2026-10-12T15:31:00.000Z", "minutesLate": -29, "risk": "on_time", "reason": null, "basis": "live",
      "enRouteAt": "2026-10-12T15:20:00.000Z", "arrivedAt": null, "completedAt": null, "plannedOrder": 2, "actualOrder": null
    }
  ]
}
techs[] field
personId, displayName, timezone, branchId, test The tech (names by the grant's peopleNames)
status off, idle, en_route, on_site; null on another day
laneStatus The board's dot: ok, tight, late, on_job, idle
currentJobId, nextJobId The job they're on now, and the next one not started
position { lat, lng, accuracy, heading, speed, recordedAt } or null (who sees a position)

jobs[] is every stop the caller may see that day, lane by lane in start order, one row per tech (a crew job has a row per tech): jobId, personId, externalRefs, clientCompanyId, stage, start (scheduled, UTC), end, local, then the risk flattened (eta, minutesLate, risk = the level, reason, basis; all null when there is none), the field taps enRouteAt, arrivedAt, completedAt, and plannedOrder, actualOrder. Drive minutes per leg (planned driveMinutesBefore, actual actualDriveMinutes) are on the board's stops.

Poll every 30 to 60 seconds with If-None-Match while someone is looking; the ETag covers everything but generatedAt, so a moving ETA or position is a change.

#Routes: GET /v1/office/dispatch/routes

For your platform's own admin map: where each tech is, which way they're heading, and the route they're on. GET /v1/office/dispatch/routes[?date=YYYY-MM-DD][&branch=<branch id>][&client=<clientCompanyId>|none][&techIds=1,2] (C read). client keeps only techs with a stop for that client (their whole route shows); techIds narrows to those people. Poll every 15 to 30 seconds with If-None-Match.

bash
curl -s "$CREWLINK/v1/office/dispatch/routes" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" \
  -H 'If-None-Match: W/"Hx2c..."'
json
{
  "date": "2026-10-12", "timezone": "America/Chicago", "generatedAt": "2026-10-12T15:30:00.000Z", "live": true,
  "branches": [], "mapStyle": null,
  "techs": [
    {
      "personId": 1, "displayName": "Maria Gomez", "timezone": "America/Chicago", "branchId": "6f1c0d3e-...", "test": false,
      "status": "en_route", "laneStatus": "ok", "currentJobId": null, "nextJobId": 1188,
      "position": { "lat": 41.958, "lng": -87.688, "accuracy": 8, "heading": 90, "speed": 12, "recordedAt": "2026-10-12T15:29:00.000Z" },
      "heading": 90, "positionState": "live", "navigating": true, "routeSource": "navigation", "progressMeters": 994,
      "route": { "type": "LineString", "coordinates": [[-87.7, 41.958], [-87.688, 41.958], [-87.69, 41.96], [-87.7, 41.96], [-87.7, 42.0]] },
      "legs": [
        { "from": "position", "fromJobId": null, "toJobId": 1188, "distanceMeters": 437, "durationSeconds": 87, "eta": "2026-10-12T15:32:00.000Z", "minutesLate": -28, "provider": "mapbox", "endIndex": 2 },
        { "from": "job", "fromJobId": 1188, "toJobId": 1190, "distanceMeters": 5278, "durationSeconds": 540, "eta": "2026-10-12T17:09:00.000Z", "minutesLate": -51, "provider": "mapbox", "endIndex": 4 }
      ],
      "stops": [
        { "jobId": 1187, "order": 1, "start": "2026-10-12T13:00:00.000Z", "end": "2026-10-12T14:00:00.000Z", "local": "2026-10-12T08:00", "stage": "done", "state": "completed", "reached": true, "eta": null, "risk": { "level": "on_time", "minutesLate": -5 }, "customer": "Ann Early", "service": "Site survey", "client": null, "address": { "full": "42 Elm St, Evanston, IL 60201", "lat": 41.95, "lng": -87.68 }, "externalRefs": {} },
        { "jobId": 1188, "order": 2, "start": "2026-10-12T16:00:00.000Z", "end": "2026-10-12T17:00:00.000Z", "local": "2026-10-12T11:00", "stage": "en_route", "state": "in_progress", "reached": false, "eta": "2026-10-12T15:32:00.000Z", "risk": { "level": "on_time", "minutesLate": -29 }, "customer": "Bo Driveway", "service": "Site survey", "client": { "id": "c41e0a77-...", "name": "Globex Solar" }, "address": { "full": "...", "lat": 41.96, "lng": -87.69 }, "externalRefs": { "acme": "proj_b" } }
      ]
    }
  ]
}
techs[] field
position, heading As on the live read, but only on the clock for every caller and by the grant (who sees a position); heading is the fix's, degrees from north
positionState live (fix under 15 min old), stale, none (location off: no fix since they came on the clock), off_clock, hidden (your grant may not see it now); null on another day. Show it honestly: "Location off", "Off the clock"
navigating true while the tech follows CrewLink's in-app directions to their next job (route fetched in the last 90 min, On my way tapped)
route GeoJSON LineString [lng, lat] of the drive they're on now: navigating, the route their phone follows then the planned legs; else planned from their position (or their current or last stop, or where the day starts) through the remaining stops in order. null when nothing is left
routeSource navigation or planned
progressMeters Metres along route where they are: draw the part before it faded (driven), the rest bold
legs[] from (position, origin, job), fromJobId, toJobId, distanceMeters, durationSeconds (from a position: what's left), eta (UTC, whole minutes, chained through the booked lengths), signed minutesLate, provider (mapbox, osrm, straight), endIndex in route.coordinates
stops[] Booked stops in order with start, end, local, stage, state, reached, eta, risk { level, minutesLate }, customer (name only), service, client, address, externalRefs
  • Same privacy as the live read, stricter: names by peopleNames; no homeowner phone; a position only on the clock; a "short" grant only while the tech is working a job with a fresh fix, and no leg from where the day starts (a home): the line begins at the first stop. mapStyle is always null for platform keys: draw with your own map.
  • Cost: routes are cached on CrewLink's side (fixed legs 30 min, a moving tech's leg 3 min while they stay on it) and capped at 6 provider calls per read and 60 a minute; a leg not routed yet is provider: "straight" (two points, the planned drive time) and fills in on a later poll. Polling at 15 to 30 s is fine; each call counts against the key's rate limit.

#Risk

Per stop (lanes[].stops[].risk on the board, flattened on the live read). Levels use the board's thresholds: on_time arrives 5 or more minutes before the start, at_risk 5 early to 10 late, late more than 10 late. minutesLate is signed: expected (or actual) arrival minus the scheduled start; negative is early.

basis Which stops How
live Today: the tech's next stop, or one due now that hasn't started The live ETA: from a fresh position while on the way, else from the job they're on (its end plus the planned drive), else from where they were last (the previous job or home) as if leaving now
chained Today: the stops after the next one, not reached yet The ETA before it, then the later of that and its start, plus its booked length and the planned drive: a stop running late makes the ones after it late
actual Any day: stops with an Arrived tap minutesLate is the arrival minus the start; eta is null

reason (null when on_time): traffic (on the way, the drive is running long), previous_overrun (the job they're on ran past its end, or this stop is chained behind a late one), not_started (they should have left, or the job is due and hasn't started), tight_schedule (the time between jobs is shorter than the drive), unknown (on the way with no fresh position). risk is null on another day for stops not arrived at, for holds, and for people with a team or own scope.

#Push: webhooks

Subscribe to these on your platform's platform-wide subscription (or a company's). Both carry companyId in the body like every event. Full payloads: webhooks.md.

  • dispatch.position: a tech's position and field status. At most one per tech per 60 seconds, and only when the tech moved 100 m or more since the last one sent or their status (off, idle, en_route, on_site) changed; only on the clock. Sent after a fix from the field app (every minute or so while on the clock) or an On the way / Arrived / Done tap. A platform endpoint gets it by the position rule (a "short" grant only while the tech is working a job). Not included in *: subscribe to it by name ("events": ["dispatch.position", ...], example). A fix from your platform's own app (POST /v1/office/team/{id}/locations) sends it too. data: personId, externalRefs (the tech's), status, previousStatus, currentJobId, nextJobId, position (lat, lng, accuracy, heading, speed, recordedAt).
  • job.risk_changed: a stop's risk level changed. CrewLink re-reads each subscribed company's board for today every minute and sends one event per stop (per tech) whose level moved. data: jobId, personId, before, after (on_time, at_risk, late; a stop never sent before counts as on_time), reason, basis, start, eta, minutesLate, plus the job's externalRef, externalSource, externalRefs. Arrivals are job.arrived.

#Who sees a position

Caller Sees a tech's position
The company's own keys and office The last one, while the tech is on the clock (the same rule as the next row; since 2026-10-13)
A platform whose grant says peopleNames: "full" (a platform on the company whose crew it runs) While the tech is on the clock: now and when the fix was taken, both between 30 minutes before their first working window or visit that day and 30 minutes after their last
Any other platform grant ("short") Only while the tech is working a job (one not done that ended at most an hour ago or starts within two hours) and the fix is at most 15 minutes old

Nobody, the company's own office included, ever sees where a tech is off the clock, and dispatch.position is never sent off the clock. The same rule applies to GET /v1/office/dispatch, GET /v1/office/dispatch/live, GET /v1/office/live, GET /v1/office/dispatch/routes and the webhook.

#Retention and limits

  • What CrewLink stores: one last known position per tech (lat, lng, accuracy, heading, speed and when it was taken), overwritten by each newer fix; a last position older than 30 days is deleted (hourly). CrewLink also keeps a location trail of on-the-clock fixes (thinned) for the company's locationRetentionDays (default 90), for mileage and pay disputes: see Location and directions. The On the way / Arrived / Done taps keep the place they were tapped (when the app sent one) in the job's history, like the rest of the job. What the two webhooks last sent (for the throttle) is kept 2 days.
  • Rate limits: the platform key's 1,200 requests a minute across all companies (304s count). Poll the board or the live read every 30 to 60 seconds per open view, or use the webhooks instead of polling. dispatch.position: at most one per tech per 60 seconds; job.risk_changed: evaluated once a minute.

#Field map

What your platform's dispatch view needs, and where it is:

Need Where
Tech id lanes[].tech.id (board), techs[].personId (live), personId (webhooks); your platform's own id in externalRefs on dispatch.position and GET /v1/office/team
Position lanes[].location { lat, lng, at, accuracy, heading, speed } (board); techs[].position { ..., recordedAt } (live); dispatch.position data.position
recordedAt lanes[].location.at = techs[].position.recordedAt
Field status idle / en_route / on_site / off lanes[].fieldStatus, techs[].status, dispatch.position data.status (the older lanes[].doing: driving = en_route, on_site, before_leaving / done = idle or off)
Current / next job lanes[].currentJobId / nextJobId, techs[].currentJobId / nextJobId
Scheduled start stops[].start (UTC), stops[].local; jobs[].start
ETA, minutes late (signed) stops[].risk.eta / .minutesLate for every stop not reached yet (stops[].eta / minutesLate: the next stop while driving only); jobs[].eta / minutesLate
Risk level on_time / at_risk / late, reason stops[].risk.level / .reason / .basis; jobs[].risk / reason; job.risk_changed (before, after)
The board's dot and risk list lanes[].status (ok, tight, late, on_job, idle), risks[] (late, tight, overdue, aging, with a message)
En route / arrived / completed stops[].enRouteAt / arrivedAt / completedAt; job reads enRouteAt / arrivedAt / doneAt and timeline; webhooks job.en_route / job.arrived / job.completed (at)
Planned vs actual order stops[].plannedOrder / actualOrder
Drive per leg, planned vs actual stops[].driveMinutesBefore (with driveFrom, driveSource) vs stops[].actualDriveMinutes
Client filter client=<clientCompanyId> (or none) on the board and the live read; stops[].clientCompanyId / client
Your platform's id for the job stops[].externalRefs, jobs[].externalRefs, externalRefs on job webhooks
Heading, route line, navigating, per-leg ETA GET /v1/office/dispatch/routes: techs[].heading, route (GeoJSON), routeSource, navigating, progressMeters, legs[]

#Location and directions

CrewLink's tech location and navigation services, for your platform's own apps: where techs are now and where they went on the clock (for mileage and pay disputes), a way for your platform's own tech app to send fixes, turn-by-turn directions to render in your platform's UI, and road distances. Same company isolation as everything else: only the company in X-CrewLink-Company, its own people and jobs; another company's person or job is 404.

Method Path Scope What
GET /v1/office/dispatch/live C read Where each tech is now (above)
webhook dispatch.position C webhooks Pushed positions (above); subscribe by name
GET /v1/office/team/{id}/locations C read, full-names grant One tech's trail for a day: fixes, road miles, stops, planned vs actual order
POST /v1/office/team/{id}/locations C write A batch of fixes from your platform's own tech app
POST /v1/office/route C read A driving route with turn-by-turn steps
POST /v1/office/route-matrix C read Road distance and drive time, up to 25 x 25

#Live location: read and subscribe

GET /v1/office/dispatch/live (read) gives each tech's position { lat, lng, accuracy, heading, speed, recordedAt } and field status; on acme-crew (a peopleNames: "full" grant) a position shows while the tech is on the clock. Details and the ETag poll: Poll.

dispatch.position is never sent to a * subscription. Name it in events (next to *, if the same URL should get everything else too) on the platform-wide subscription; the company must have granted webhooks:

bash
curl -s -X POST $CREWLINK/v1/platform/webhooks -H "authorization: Bearer $PK" -H 'content-type: application/json' \
  -d '{"url": "https://api.acme.example/hooks/crewlink-live", "events": ["dispatch.position", "job.risk_changed"]}'
# one subscription for everything: "events": ["*", "dispatch.position"]

It is throttled (one per tech per 60 s, after 100 m of movement or a status change) and on the clock only. A fix your platform sends with POST /v1/office/team/{id}/locations (below) triggers it like a field app fix.

#The location trail: GET /v1/office/team/{id}/locations

GET /v1/office/team/{id}/locations[?date=YYYY-MM-DD], or ?from=<ISO instant>&to=<ISO instant> (at most 24 hours apart). date is a local day in the tech's zone (default today).

bash
curl -s "$CREWLINK/v1/office/team/1/locations?date=2026-10-12" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew"
json
{
  "personId": 1, "displayName": "Maria Gomez", "timezone": "America/Chicago", "date": "2026-10-12",
  "from": "2026-10-12T05:00:00.000Z", "to": "2026-10-13T05:00:00.000Z", "retentionDays": 90,
  "fixes": [
    { "at": "2026-10-12T13:00:00.000Z", "lat": 41.9, "lng": -87.68, "accuracy": 8, "heading": null, "speed": null, "source": "acme" },
    { "at": "2026-10-12T13:16:00.000Z", "lat": 41.909, "lng": -87.68, "accuracy": 8, "heading": 0, "speed": 15.2, "source": "app" }
  ],
  "summary": {
    "fixCount": 67, "firstFixAt": "2026-10-12T13:00:00.000Z", "lastFixAt": "2026-10-12T14:10:00.000Z",
    "distanceMeters": 15000, "miles": 9.32, "distanceMethod": "map_matched",
    "stops": [
      { "from": "2026-10-12T13:00:00.000Z", "to": "2026-10-12T13:15:00.000Z", "minutes": 15, "lat": 41.9, "lng": -87.68, "jobId": null },
      { "from": "2026-10-12T13:25:00.000Z", "to": "2026-10-12T13:40:00.000Z", "minutes": 15, "lat": 41.99, "lng": -87.68, "jobId": 1188 },
      { "from": "2026-10-12T13:50:00.000Z", "to": "2026-10-12T14:10:00.000Z", "minutes": 20, "lat": 41.95, "lng": -87.68, "jobId": 1187 }
    ],
    "visits": [
      { "jobId": 1187, "plannedStart": "2026-10-12T14:00:00.000Z", "plannedOrder": 1, "arrivedAt": "2026-10-12T13:50:00.000Z", "departedAt": "2026-10-12T14:10:00.000Z", "dwellMinutes": 20, "actualOrder": 2 },
      { "jobId": 1188, "plannedStart": "2026-10-12T16:00:00.000Z", "plannedOrder": 2, "arrivedAt": "2026-10-12T13:25:00.000Z", "departedAt": "2026-10-12T13:40:00.000Z", "dwellMinutes": 15, "actualOrder": 1 }
    ],
    "plannedOrder": [1187, 1188], "actualOrder": [1188, 1187]
  }
}
Field
fixes[] On-the-clock fixes in time order, thinned (below): at, lat, lng, accuracy (m), heading (degrees from north), speed (m/s), source (app: CrewLink's field app; acme: your platform's app)
summary.distanceMeters, miles Road distance over the span. distanceMethod: map_matched (snapped to roads with OSRM map matching; points it can't match, and the jumps between matched pieces, count as straight lines), segments (the sum of straight lines between fixes, where there is no road data), none (fewer than two fixes). Fixes less precise than 100 m don't count toward distance
summary.stops[] Places the tech stayed within 75 m for 3 minutes or more; jobId is the visit whose address is within 150 m, else null
summary.visits[] The tech's visits on the span's local days, in plan order: plannedStart, plannedOrder, and from the trail arrivedAt (start of the first stop there), departedAt (end of the last), dwellMinutes, actualOrder (null if the trail never reached it). These come from GPS, not the taps (those are enRouteAt / arrivedAt / doneAt on the job)
summary.plannedOrder, actualOrder Job ids in plan order, and in the order the trail reached them

Who may read it: the company's owners and company-scope dispatchers, and a platform with read on a grant with peopleNames: "full" (a platform that operates the company: your platform on acme-crew). Any other platform grant gets 403 forbidden; narrowed office people (team or own scope) get 403 scoped. 422 invalid_request: from without to (or with date), more than 24 hours, or a date that isn't real.

For mileage and pay, use summary.miles (road distance) per day, or POST /v1/office/route-matrix for job-to-job road distances. A disputed day can be checked against fixes and stops for as long as the company keeps them (retentionDays).

#Sending fixes from your platform's app: POST /v1/office/team/{id}/locations

For your platform's own tech app, when a tech works from it instead of CrewLink's field app. Write scope, platform keys only (403 for anyone else; techs on CrewLink's app use POST /v1/tech/location).

bash
curl -s -X POST "$CREWLINK/v1/office/team/1/locations" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" -H 'content-type: application/json' \
  -d '{"fixes": [
        {"at": "2026-10-12T13:16:00Z", "lat": 41.909, "lng": -87.68, "accuracy": 8, "heading": 0, "speed": 15.2},
        {"at": "2026-10-12T13:17:00Z", "lat": 41.918, "lng": -87.68, "accuracy": 7}
      ]}'
json
{ "received": 2, "stored": 2, "duplicates": 0, "thinned": 0, "offClock": 0, "rejected": [], "positionUpdated": true }
  • Body: fixes, 1 to 500 per call, any order. Each: at (ISO instant, when the phone took it), lat, lng, and optional accuracy (m, 0 to 100,000), heading (0 to 360), speed (m/s, 0 to 100). A malformed batch (more than 500, a coordinate out of range, an at that isn't an instant) is 422 invalid_request and nothing is stored.
  • Per fix: an at more than 7 days old (too_old), more than a minute in the future (in_future), or 0,0 (invalid_point) is listed in rejected with its index; the rest of the batch goes on.
  • On the clock only. A fix taken off the clock (outside 30 minutes either side of that day's working windows and visits, the position rule) is counted in offClock and dropped: not stored, not a live position.
  • Thinned: a fix is kept only 25 m or more from the kept fix before it, or 60 s or more after it, and never within 10 s of a kept fix (at most one every 10 s). Send what the phone has; CrewLink thins it.
  • Deduplicated by (tech, at): a fix at an instant already stored is a duplicate, so resending a batch after a timeout is safe (an Idempotency-Key also works).
  • The newest on-the-clock fix becomes the tech's live position (the board, the live read, dispatch.position) unless a newer one is already known (positionUpdated). Stored fixes carry source: "acme".

Consent. your platform must hold each tech's consent to location tracking before sending their fixes (CrewLink can't see your platform's consent screen, so the integration is responsible for it), and should send fixes only while the tech is working. CrewLink stores only on-the-clock fixes, whatever is sent.

#Directions: POST /v1/office/route

A driving route to render in your platform's own UI (don't hand the user off to Google or Apple Maps). Read scope. The same route the field app gets: Mapbox driving-traffic (live traffic) first, OSRM (free flow) when Mapbox is missing or fails, cached 5 minutes per trip.

bash
curl -s -X POST "$CREWLINK/v1/office/route" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" -H 'content-type: application/json' \
  -d '{"from": {"lat": 41.9, "lng": -87.68}, "jobId": 1187, "waypoints": [{"lat": 41.93, "lng": -87.69}]}'
Body
from { lat, lng } Required
to or jobId { lat, lng }, or one of the company's job ids (its address) Exactly one (422 otherwise; 404 for another company's job)
waypoints Up to 23 { lat, lng }, in order Optional
departAt ISO instant Optional, default now. At least a minute ahead: Mapbox's typical traffic for then. OSRM is free flow either way; a past time counts as now
profile "driving" Optional; the only profile
includeMapStyle boolean Optional: also answer mapStyle (CrewLink's navigation style and the public pk. token only). Without it mapStyle is null: no token is handed out

The answer has the field app's route shape (field-app.md): from, to ({ lat, lng, address }), jobId, waypoints, provider (mapbox or osrm), profile, distanceMeters, durationSeconds, departAt, eta (departAt plus the duration), geometry (GeoJSON LineString, [lng, lat]), steps[] (instruction, name, distanceMeters, durationSeconds, maneuver { type, modifier, location, bearingAfter }, voice, and lanes when known; an arrive step at each waypoint), maxspeed (one posted limit per segment of geometry, when known), mapStyle. Errors: 422 invalid_request / no_destination, 404 not_found, 429 rate_limited (60 a minute per company, shared by its users and keys; per platform for a platform key), 502 route_unavailable, 503 not_configured.

#Distance matrix: POST /v1/office/route-matrix

Road distance and drive time from every origin to every destination, from CrewLink's road data (OSRM table). Read scope. Made for mileage and pay: distances are road distances, never straight lines.

bash
curl -s -X POST "$CREWLINK/v1/office/route-matrix" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" -H 'content-type: application/json' \
  -d '{"origins": [{"lat": 41.9, "lng": -87.68}], "destinations": [{"lat": 41.95, "lng": -87.68}, {"lat": 41.99, "lng": -87.68}], "planned": true}'
json
{ "provider": "osrm", "departAt": "2026-10-12T15:30:00.000Z", "origins": [{ "lat": 41.9, "lng": -87.68 }], "destinations": [{ "lat": 41.95, "lng": -87.68 }, { "lat": 41.99, "lng": -87.68 }],
  "distancesMeters": [[5712, 10388]], "durationsSeconds": [[540, 905]], "miles": [[3.55, 6.45]], "plannedSeconds": [[1020, 1500]] }
  • origins and destinations: 1 to 25 each (422 beyond). Cells are [origin][destination]; null where there is no road route.
  • durationsSeconds is free flow. planned: true adds plannedSeconds: the drive CrewLink's planning allows leaving at departAt (default now), with its time-of-day traffic model and margin and without the company's buffer between jobs.
  • Errors: 429 rate_limited (30 a minute per company, shared by its users and keys; per platform for a platform key), 502 matrix_unavailable (OSRM failed, or the points span two road regions), 503 not_configured (no road data on this server).

#Privacy, retention and limits

What is stored (1) One last known position per tech, overwritten by each newer fix, deleted after 30 days. (2) The location trail: on-the-clock fixes from CrewLink's field app (source: "app") and your platform's app ("acme"), thinned to one per 25 m or 60 s, at most one per 10 s. Nothing about routes or matrices is stored
On the clock only A fix goes into the trail only when it was taken within 30 minutes either side of that day's working windows and visits (the position rule). Off-shift fixes are never stored in the trail; from your platform they are dropped entirely
Retention The trail is kept for the company's locationRetentionDays: default 90, 7 to 365, set by the company's owner (PATCH /v1/office/settings; platforms can't change it). Older fixes are deleted by the hourly retention job. GET /v1/office/me (company.locationRetentionDays) and the trail answer (retentionDays) show it
Consent The company (for CrewLink's field app, per its GPS policy) or the integrator (for its own app) must have the tech's location consent. CrewLink adds the on-the-clock rule on top
People's names The trail is only for a grant with peopleNames: "full" (a platform that operates the company); every other platform grant gets 403. Live positions follow who sees a position
Rate limits The platform key's 1,200 a minute for everything, plus, per platform key (a company's own users and keys share one budget per company): POST /v1/office/route 60 a minute, POST /v1/office/route-matrix 30 a minute (429 rate_limited). The trail write takes up to 500 fixes: send a few minutes of fixes per call, not one call per fix
Map token No Mapbox token is handed out unless includeMapStyle: true, and then only the public pk. token

What your platform's clients (installers) may see. Never a tech's exact position or trail. Show status only ("on the way", "on site", the ETA); if a map is shown, round the position to 100 m or coarser (3 decimals of latitude and longitude, or fewer) and show it only while the tech is on the way to that client's job. Names only as What your platform sees of people allows, and never the trail, miles or stops: those are for your platform's own admins (dispatch, mileage and pay).

#externalRef conventions

A job can carry several external ids, one per source: externalRefs: { "<source>": "<id>" }.

json
{ "externalRefs": { "acme": "appt_88213", "crewlink-v1": "J-9912" } }
  • Send Your platform's own id for the appointment when you book (POST /v1/office/jobs): either the map, or the shorthand externalRef (+ optional externalSource), which writes one entry. The shorthand's source defaults to acme (your platform slug), so {"externalRef": "appt_88213"} is {"externalRefs": {"acme": "appt_88213"}}. Both may be sent if they agree (422 if externalRef and externalRefs[source] differ).
  • Use more entries for other id spaces you keep (an older system, a CRM): crewlink-v1, salesforce, ... Sources: letters, digits and . _ : -, up to 100. Ids: up to 200 characters. Up to 10 per job.
  • Each (source, id) is unique per company among jobs that aren't cancelled: a booking (or visit request) that carries any id already on another live job answers 409 duplicate_external_ref with that job's jobId, and nothing is booked. That makes a lost response safe: retry with the same ids (or the same Idempotency-Key) and you either get the job or learn its id. One exception, at a site: a visit that joins the site of the live visit holding the id (a revisit service, or one sent with that siteId) may carry it too. Per-visit ids (a source ending in Visit, such as acmeVisit, and crewlink-v1) never share.
  • Cancel and book again with the same id. Once every job holding an id is cancelled (a declined request counts as cancelled), a new job may take it: 201 with a new jobId. The cancelled job keeps its ids as history (its reads and webhooks still show them). There is no reopening a cancelled job; book again instead.
  • Find a job by any of its ids: GET /v1/office/jobs?externalSource=crewlink-v1&externalRef=J-9912 (either filter alone works too: every job with an acme id, or every job with that id under any source). Every job that has the id is listed, a cancelled one too; with externalRef and no sort, the job that isn't cancelled comes first, then the newest filed, so jobs[0] is the job the id points to now (all cancelled: the most recent one).
  • Every read returns externalRefs (all of them) plus externalRef / externalSource, the job's primary id: the shorthand pair when you sent it, else your own source's entry, else the first. Every webhook about a job carries all three (empty map and nulls for jobs booked without ids, for example from the company's own booking page).
  • The company's public booking page and website callers can't use acme (or any granted platform's slug) as a source (422 reserved_source), so nobody else can take your ids.
  • Out-of-area requests take the same fields (POST /v1/public/{slug}/requests, and POST /v1/office/requests with your key, which also takes clientCompanyId) and keep them when the office accepts the request.
  • Ids are set when the job is created; they can't be changed afterwards (ask CrewLink if you need that). A site's own ids can: PATCH /v1/office/sites/{id}.

#Sites: one per property per client company

A CrewLink job (jobId) is one visit (appointment). A site is one property for one client company (or your own work), with every visit ever made there, forever. Every visit belongs to one site (siteId on job reads, on the 201/202 of POST /v1/office/jobs, and on every job webhook); the office sees one card per site. Everything about visits is unchanged: ids, externalRefs, booking.* and job.* mean what they did. Full reference: office.md.

How your platform's visits are grouped:

  • Address + client company. Every visit at the same normalized address for the same client company is one site, whoever the homeowner is and however long ago. Two client companies never share a site.
  • The acme id maps to one site. On a live booking (not an import), a visit carrying the acme id of an earlier visit goes to that visit's site, even if the address was written differently. Imported visits (source: "import") group by address and client only: their acme ids stay on the visit.
  • externalRefs.acmeVisit (optional): your platform's id for the one visit. Stored and searchable like any id, never used to group, never shared by two live visits (409 duplicate_external_ref). crewlink-v1 (the old app's request ids) is per visit too.
  • Your platform's own work (no clientCompanyId) at a client's property goes to that client's site when exactly one client company has a site there (the site keeps the client); otherwise to your platform's own site.
  • Booking a second visit while the first is live with the same acme id: a revisit service may carry it; any other service needs siteId (from the first visit's siteId); without it the same acme id is still 409 duplicate_external_ref, as before. After a cancel the id is free as it always was.

Read them with GET /v1/office/sites (?externalSource=acme&externalRef=<your platform job id> finds the site; updatedSince + sort=updated_asc syncs) and GET /v1/office/sites/{id} (every visit and the timeline). GET /v1/office/jobs?siteId= lists one site's visits. A site's status is active while a visit is scheduled, has no time yet or needs attention, else idle, with lastVisit and nextVisit. The office (or your write key) can merge two sites (the absorbed one stays readable as merged with mergedIntoId) and split a visit out. Webhooks: site.created, site.updated, site.merged (payloads); an endpoint on * gets them too.

Your platform's client companies (the installers, say Globex Solar) can give their sales reps a booking link: the rep opens it on a phone and books a site survey with no account, for that client. The page shows the client's name only, never your platform's people, CrewLink or a tech's name. The full guide (the page, its public routes, what a booking records, abuse controls): booking-page.md.

With the platform key for the provider company (X-CrewLink-Company: acme-crew):

Call Scope What
GET /v1/office/clients/{clientId}/share-links read The client's links with url, fallbackUrl, status and usage
POST /v1/office/clients/{clientId}/share-links write Make one: { label?, rep?: { name, email }, serviceIds?, expiresAt?, dailyCap? (25), active? }. One general link per client (409 link_exists with linkId), one per rep email
GET /v1/office/clients/{clientId}/share-links/{linkId} read One link with history (created, updated, rotated, revoked: when and by whom)
PATCH /v1/office/clients/{clientId}/share-links/{linkId} write label, rep (null: the general link), serviceIds, expiresAt, dailyCap, active
POST /v1/office/clients/{clientId}/share-links/{linkId}/rotate write New URL, same link
DELETE /v1/office/clients/{clientId}/share-links/{linkId} write Revoke

clientId is the client company's id (GET /v1/office/clients). The other client company calls (creating, renaming, keys) stay off limits to platform keys; share links are the exception.

bash
curl -s -X POST "$CREWLINK/v1/office/clients/$CLIENT_ID/share-links" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H 'X-CrewLink-Company: acme-crew' -H 'Content-Type: application/json' \
  -H "Idempotency-Key: share-$CLIENT_ID-general" -d '{"label":"Spring door knocking","dailyCap":25}'

Jobs from a link are the provider's jobs with clientCompanyId the client, source: "share_link", shareLinkId and bookedBy: { repName, repEmail }, on job reads, on booking.created / request.created and on every job webhook (additive fields, null for every other job). A link your platform's key made books like your platform: the grant's homeownerMessaging: off means the homeowner hears nothing about those jobs, ever (the company's own switch applies too). Links the office made follow the company's switch.

Your platform's own domain. The same link can be served at https://book.acme.example/r/<token> (with go.crewlink.ai/r/<token> still working): see Your own domain.

#Idempotency and retries

Send Idempotency-Key: <unique string> on every write (POST, PUT, PATCH, DELETE). The first request with a key runs; a retry with the same key and the same body gets the first answer back (with Idempotent-Replayed: true) instead of acting twice.

  • Keys are per platform and company, kept 24 hours. Use something derived from your own ids (acme-appt-88213-book, acme-appt-88213-move-2), or a UUID you store with the attempt.
  • The same key with a different body: 422 idempotency_key_reused. While the first request still runs: 409 in_progress (retry shortly).
  • 5xx answers aren't kept, so a retry runs again.
  • Letters, digits and . _ : -, up to 200 characters.
  • Secrets aren't kept: a replayed answer has manageToken, holdSecret and similar fields empty (""). A replayed hold therefore can't be booked; hold again. Subscribing a webhook ignores the header (its secret is shown once).

Retry 5xx and network errors with backoff (1 s, 2 s, 4 s, ...). Don't retry 4xx unchanged, except 429 after Retry-After.

#Webhooks

#One subscription for every company

bash
curl -s -X POST $CREWLINK/v1/platform/webhooks -H "authorization: Bearer $PK" -H 'content-type: application/json' \
  -d '{"url": "https://api.acme.example/hooks/crewlink", "events": ["*"]}'
json
{ "id": "5d2b8f1e-...", "url": "https://api.acme.example/hooks/crewlink", "events": ["*"], "active": true, "historyEvents": false, "secret": "whsec_<signing secret>" }
  • It receives the events of every company that granted your platform the webhooks scope, now and later, plus platform.grant.created / platform.grant.revoked. No per-company setup.

  • The secret is shown once, here. It belongs to this subscription only (company owners never see it). To rotate: add a second subscription, accept both secrets for a moment, delete the old one.

  • Same URL rules as company webhooks: https on a public host name, no redirects. See webhooks.md.

  • History emissions are off until you turn them on (historyEvents). A past time written as history (booking.created / booking.rescheduled with history: true), an unassign (booking.rescheduled with unassigned: true) and a silent completion (job.completed with silent: true) are sent only to subscriptions with historyEvents: true, so a receiver that isn't ready never tells a client their visit "moved" when it is about a job in the past. It is false for every existing subscription and for new ones unless POST sends "historyEvents": true. When your handler is ready (it should treat history: true as a record of the past, never a client notice), turn it on:

    bash
    curl -s -X PATCH $CREWLINK/v1/platform/webhooks/5d2b8f1e-... -H "authorization: Bearer $PK" -H 'content-type: application/json' \
      -d '{"historyEvents": true}'

    The answer is the subscription with "historyEvents": true; GET /v1/platform/webhooks shows it. It also works on a company-level endpoint your platform made (or use PATCH /v1/office/webhooks/{id} with X-CrewLink-Company). Every other event is unchanged either way. Details: webhooks.md.

#What a delivery looks like

http
POST /hooks/crewlink HTTP/1.1
Content-Type: application/json
CrewLink-Event: job.completed
CrewLink-Delivery: 58301
CrewLink-Signature: t=1793725800,v1=5f0c3a1d9e8b7c6a5f4e3d2c1b0a99887766554433221100ffeeddccbbaa9988

{"id":58301,"event":"job.completed","createdAt":"2026-11-03T16:10:00.000Z","companyId":"8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b","data":{"source":"field_app","silent":false,"jobId":1042,"personId":3,"at":"2026-11-03T16:10:00.000Z","etaMinutes":null,"minutesOnSite":88,"externalRef":"appt_88213","externalSource":"acme","externalRefs":{"acme":"appt_88213"}}}
  • companyId says which company the event is about; match it with X-CrewLink-Company ids from GET /v1/platform/grants.
  • Answer any 2xx within 10 seconds, then do the work. Failures retry after 1, 5, 30, 120 and 720 minutes.
  • Deliveries can repeat and arrive out of order: skip ids you've seen, and compare createdAt (or re-read the job) before overwriting newer state.

#Delivery log

When an event seems missing, look it up before anything else: was it queued for your subscription, and what did your server answer?

bash
curl -s "$CREWLINK/v1/platform/webhooks/$SUB/deliveries?jobId=5679" -H "authorization: Bearer $PK"
json
{
  "deliveries": [
    {
      "id": 61877, "endpointId": "5d2b8f1e-...", "event": "booking.cancelled", "companyId": "8a1f0c3e-...",
      "status": "delivered", "createdAt": "2026-10-12T05:02:10.000Z", "attempts": 1,
      "lastAttemptAt": "2026-10-12T05:02:14.000Z", "nextAttemptAt": null, "lastStatusCode": 200, "lastError": null,
      "deliveredAt": "2026-10-12T05:02:14.000Z",
      "payload": { "id": 61877, "event": "booking.cancelled", "createdAt": "2026-10-12T05:02:10.000Z", "companyId": "8a1f0c3e-...", "data": { "bookingId": 5679, "jobId": 5679, "...": "..." } }
    },
    {
      "id": 61802, "endpointId": "5d2b8f1e-...", "event": "booking.created", "companyId": "8a1f0c3e-...",
      "status": "pending", "createdAt": "2026-10-12T04:58:03.000Z", "attempts": 2,
      "lastAttemptAt": "2026-10-12T04:59:07.000Z", "nextAttemptAt": "2026-10-12T05:04:07.000Z", "lastStatusCode": 500, "lastError": "HTTP 500",
      "deliveredAt": null, "payload": { "...": "..." }
    }
  ],
  "next": null
}
  • Filters: event, jobId (data.jobId, data.bookingId or data.requestId), status (pending, delivered, failed), limit (1 to 200, default 50), before (the next of the previous page). Newest first.
  • lastStatusCode is what your server answered; null with a lastError such as Refused: resolves to a non-public address or Timed out means no answer came back. A 3xx is a failure (redirects are not followed): point the subscription at the final URL.
  • Only deliveries about companies that grant your platform access now are listed. The same log works for a company-level endpoint your platform made (GET /v1/office/webhooks/{id}/deliveries with X-CrewLink-Company, or this route).
  • POST /v1/platform/webhooks/{id}/deliveries/{deliveryId}/retry queues one again now (same id and body; sent within about 10 seconds), whatever its status. Full reference: webhooks.md.

#Verifying the signature

CrewLink-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the whole secret (including whsec_). Reject a t more than 5 minutes from your clock.

js
// Node.js (Express). Use the raw body: re-serialized JSON won't match.
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.CREWLINK_WEBHOOK_SECRET;
const app = express();

app.post('/hooks/crewlink', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(String(req.get('crewlink-signature') ?? '').split(',').map((p) => p.split(/=(.*)/s).slice(0, 2)));
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
  const ok = parts.v1 && parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok || Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end();
  const event = JSON.parse(req.body);
  queue.push(event); // process after answering
  res.status(200).end();
});
python
# Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

SECRET = os.environ["CREWLINK_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/hooks/crewlink")
def crewlink():
    raw = request.get_data()  # the raw bytes
    parts = dict(p.split("=", 1) for p in request.headers.get("CrewLink-Signature", "").split(",") if "=" in p)
    t = int(parts.get("t", "0"))
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - t) > 300:
        abort(401)
    queue.put(json.loads(raw))  # process after answering
    return "", 200

#The events your platform cares about

Full payloads for every event are in webhooks.md. All job events carry jobId (booking events: bookingId, the same number), externalRef, externalSource, externalRefs and siteId (the site). site.created, site.updated and site.merged follow the property (payloads).

Event When
booking.created A job was booked (source: platform for your platform's, office, booking_page, ...)
booking.rescheduled It moved (from: the old local time)
booking.cancelled It was cancelled (reason; wasDone: true when it was already done: the job then reads cancelledAfterDone: true and its techs' pay was taken back)
job.en_route The tech is on the way (etaMinutes)
job.arrived The tech arrived
job.completed Done: source field_app (the tech), api (a key, such as your platform's complete) or office (a signed-in office user); silent: true for a silent completion (POST /v1/office/jobs/{id}/complete with silent: true), sent only to subscriptions with historyEvents: true
job.running_late The tech said the current job runs long; later jobs were handled
job.risk_changed A stop's risk level changed (before, after, reason, eta, minutesLate): Live positions and risk
dispatch.position A tech moved or changed status, on the clock (throttled; subscribe by name, not in *): Live positions and risk
job.needs_attention Someone flagged the job (reason, source, by: the platform slug or null), or CrewLink did (source: "system"; by: "closeout" for a job not closed out: not marked done 3 hours after its end or by 21:00, once per job; sent for notify: false jobs too)
attachment.uploaded A file was added (attachment, uploadedBy)
request.created An out-of-area request came in (externalRef when one was sent)
person.updated, availability.updated, person.base.updated A tech changed, their weekly hours changed, a base was added or removed (Keeping techs in step)
block.created, block.deleted Time off added or removed (externalRefs on created)
platform.grant.created / platform.grant.revoked A company granted or revoked your platform (platform-wide subscription only)
external_message.created, .updated, .deleted The company's office answered in a job's mirrored conversation, or edited or deleted its answer (only to the platform that owns it)

#booking.created for a job your platform booked

Every booking.* event carries the same core fields (webhooks.md): bookingId and jobId (the same number), status, start / local and end / localEnd, timezone, techs (tech ids, integers), clientCompanyId and client, plus your platform's ids. A job your platform booked with its key (here for a client company) arrives as:

json
{
  "id": 61802,
  "event": "booking.created",
  "createdAt": "2026-10-12T04:58:03.000Z",
  "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
  "data": {
    "bookingId": 5679,
    "jobId": 5679,
    "source": "platform",
    "status": "booked",
    "serviceId": 4,
    "service": "Residential Solar Site Survey",
    "start": "2026-10-14T14:00:00.000Z",
    "local": "2026-10-14T09:00",
    "end": "2026-10-14T15:30:00.000Z",
    "localEnd": "2026-10-14T10:30",
    "timezone": "America/Chicago",
    "techs": [81],
    "clientCompanyId": "9b1e6f2a-4c3d-4e8f-a7b6-2d1c0e9f8a75",
    "client": { "id": "9b1e6f2a-4c3d-4e8f-a7b6-2d1c0e9f8a75", "name": "Acme Test Client" },
    "externalRef": "appt_88213",
    "externalSource": "acme",
    "externalRefs": { "acme": "appt_88213" }
  }
}

techs are ids only; read names with GET /v1/office/team (shown to your platform as "First L."). Cancelling it (POST /v1/office/jobs/{id}/cancel, with or without notify: false) sends booking.cancelled with status: "cancelled", source: "office", reason and the techs who were going; notify only decides whether the homeowner is texted, never whether the webhook goes out.

#platform.grant.created and platform.grant.revoked

json
{
  "id": 58410,
  "event": "platform.grant.created",
  "createdAt": "2026-10-12T15:30:00.000Z",
  "companyId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
  "data": {
    "platform": "acme",
    "company": { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "slug": "brightpath-solar", "name": "BrightPath Solar", "timezone": "America/Denver" },
    "scopes": ["read", "webhooks", "write"],
    "grantedAt": "2026-10-12T15:30:00.000Z",
    "revokedAt": null
  }
}

platform.grant.revoked has the same shape with revokedAt set. Changing the scopes of a live grant sends nothing; GET /v1/platform/grants always has the current scopes.

#Job objects

GET /v1/office/jobs returns summaries; GET /v1/office/jobs/{id} adds the rest.

json
{
  "id": 1042,
  "status": "booked",
  "stage": "done",
  "service": { "id": 41, "name": "Site survey", "category": "residential" },
  "start": "2026-11-03T14:30:00.000Z",
  "local": "2026-11-03T08:30",
  "end": "2026-11-03T16:18:00.000Z",
  "endLocal": "2026-11-03T10:18",
  "timezone": "America/Chicago",
  "address": "1801 W Irving Park Rd, Unit 2, Chicago, IL 60613, US",
  "lat": 41.955,
  "lng": -87.675,
  "customer": { "name": "Pat Quinn", "phone": "+1 312 555 0177", "email": "pat@example.com" },
  "tech": { "id": 3, "displayName": "Maria G." },
  "techs": [{ "id": 3, "displayName": "Maria G." }],
  "externalRef": "appt_88213",
  "externalSource": "acme",
  "externalRefs": { "acme": "appt_88213", "crewlink-v1": "J-9912" },
  "needsAttention": null,
  "doneAt": "2026-11-03T16:10:00.000Z",
  "createdAt": "2026-10-12T15:30:02.118Z",
  "updatedAt": "2026-11-03T16:10:00.412Z",
  "addressParts": { "line1": "1801 W Irving Park Rd", "line2": "Unit 2", "city": "Chicago", "region": "IL", "postcode": "60613", "country": "US" },
  "notes": "Gate code 4411. Two-story, south-facing roof.",
  "requested": null,
  "visits": [{ "tech": { "id": 3, "displayName": "Maria G." }, "date": "2026-11-03", "start": "2026-11-03T14:30:00.000Z", "end": "2026-11-03T16:18:00.000Z", "local": "2026-11-03T08:30", "endLocal": "2026-11-03T10:18" }],
  "timeline": [
    { "kind": "created", "at": "2026-10-12T15:30:02.118Z", "by": "system", "tech": null, "text": null },
    { "kind": "en_route", "at": "2026-11-03T14:05:00.000Z", "by": "tech", "tech": { "id": 3, "displayName": "Maria G." }, "text": null },
    { "kind": "arrived", "at": "2026-11-03T14:28:00.000Z", "by": "tech", "tech": { "id": 3, "displayName": "Maria G." }, "text": null },
    { "kind": "done", "at": "2026-11-03T16:10:00.000Z", "by": "tech", "tech": { "id": 3, "displayName": "Maria G." }, "text": null }
  ],
  "attachments": [{ "id": "c0f7a1d2-...", "jobId": 1042, "kind": "proposal", "name": "Proposal - Quinn.pdf", "contentType": "application/pdf", "bytes": 482113, "status": "uploaded", "uploadedBy": { "kind": "platform", "id": "6d0c1f9a-..." }, "createdAt": "...", "uploadedAt": "...", "url": "https://..." }],
  "textsCount": 3,
  "arrivedAt": "2026-11-03T14:28:00.000Z",
  "enRouteAt": "2026-11-03T14:05:00.000Z"
}
  • status: booked, held (listed only with status=held), waiting (out-of-area request), unscheduled (bumped by an emergency, no new time yet), declined, cancelled, passed (CrewConnect). stage: scheduled, en_route, on_site, done.
  • start/local are null for waiting, unscheduled and declined. end is the longest visit's booked end on the first day. local, endLocal and visit times are the wall clock in timezone, the zone of the job's address (store start, show local).
  • tech is the first assigned tech (by id); techs is everyone.
  • outsideHours: true when the job was booked outside its tech's working hours (an import honouring the old app's time); show it to the office. A move clears it.
  • timeline kinds: created, en_route, arrived, checkin, done, note, attention, attention_cleared; by is tech, office (anyone with an office or platform key) or system.
  • updatedAt changes whenever the job row changes (booked, moved, stage, attention, ...). It's the sync cursor. (The one-time grouping of jobs booked before sites existed left it alone.)
  • siteId and siteVisit ({ number, of, revisit }, "Visit 3 at this site"; cancelled visits aren't counted): the site the visit is at.
  • Field timestamps (detail): enRouteAt (the first On the way tap), arrivedAt, doneAt (completed); null until they happen.

#Mirroring conversations

Every conversation people have in your platform about a job (with the installer, with the tech, your platform's internal notes, DMs and group threads) can be mirrored into CrewLink, so the office sees it next to the schedule. CrewLink keeps it as a separate, office-only log: it is never written into CrewLink's own messaging, and it never notifies anyone.

#The rules

  • Nobody is notified. Mirroring a message (create, update, edit, delete, backfill) queues no text, email or push to techs, clients or homeowners, posts nothing in CrewLink messaging, and sends no webhook, whatever the job's or grant's messaging settings say. This is enforced on the server; there is no flag to turn it on.
  • Office only. Office users see it on the job (owners, dispatchers and field managers within their scope; DMs and groups outside a job: company-scope office only). Techs never see it (not in the field app, not in messaging), client companies never see it, and another company's ids are a 404. Another platform granted on the same company sees none of your platform's messages, and your platform sees only its own and the office's replies to it.
  • Platform keys still can't use /v1/messaging, and message.* events never go to a platform. Mirrored conversations are a different store with their own endpoints and their own events.
  • Two ways. When an office user answers in the job's Conversations tab (in the client, tech or internal channel), edits that answer or deletes it, CrewLink sends external_message.created, .updated or .deleted to the platform that owns the job's conversation, and to no one else.

#Addressing

What Syntax
A job CrewLink's job id (4568), or ref:<your id> for the job you booked under your own id: ref:appt_88213 means the job whose externalRefs.acme is appt_88213 (another source: add ?externalSource=<source>; in a backfill item, jobExternalRef / jobExternalSource). A cancelled appointment still matches, so its history can be mirrored; the live job wins when there are several
A message CrewLink's message id (5521), or ref:<your message id> (ref:msg_1001, your externalRefs.acme)
A thread outside a job threadKey: your thread id, 1 to 200 characters, starting with a letter or digit, then letters, digits and . _ : - (regex ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$), e.g. dm_usr412_usr77 or grp:crew:9

Put the ref: value in the path as is (/v1/office/jobs/ref:appt_88213/external-messages); percent-encode anything outside letters, digits and _ . : -.

#A message

POST /v1/office/jobs/{id}/external-messages (scope write):

json
{
  "channel": "client",
  "body": "Can the tech check the **main panel** too?",
  "author": { "externalId": "usr_412", "displayName": "Hana Okafor", "role": "installer" },
  "externalRefs": { "acme": "msg_1001", "acmeJob": "job_900", "acmeVisit": "visit_3" },
  "createdAt": "2026-10-01T12:00:00Z",
  "attachments": [{ "name": "roof.jpg", "type": "image/jpeg", "size": 204800, "url": "https://files.acme.example/roof.jpg?X-Amz-Signature=..." }]
}
Field
channel client, tech, internal, dm or system
body Markdown, up to 20,000 characters. May be empty when there are attachments
author displayName (required), externalId (your id for the person), role (free text, up to 40), personId (the CrewLink person, when you know it; 422 if it isn't one of the company's). With no personId, a CrewLink person whose externalRefs.acme equals externalId is linked
externalRefs.acme Required. Your message id: unique per company, it makes every write idempotent
externalRefs.acmeJob Your job (the case at one address) it belongs to, kept as caseRef for you. Defaults to the CrewLink job's own acme id. CrewLink itself groups by site: every message on a job carries the job's siteId (as it is now: a site merge moves it)
externalRefs.acmeVisit Your visit id, optional
createdAt When it was written in your platform (default now; any time in the past; not more than 5 minutes ahead)
editedAt, deletedAt When it was last edited or deleted in your platform (a deleted one is stored as a tombstone)
threadKey Your thread id when the job's message is in a group or DM
attachments Up to 20: { name, type, size, url } with an https link (copied), or { name, type, size, key } for a file uploaded to CrewLink first

Answer: 201 with { message, status: "created" }, or 200 with status updated (a message with that id was there and now matches what you sent) or unchanged.

json
{
  "status": "created",
  "message": {
    "id": 5521, "jobId": 4568, "threadKey": null, "channel": "client", "source": "platform", "platform": "acme",
    "author": { "externalId": "usr_412", "displayName": "Hana Okafor", "role": "installer", "personId": null, "userId": null },
    "body": "Can the tech check the **main panel** too?",
    "attachments": [{ "name": "roof.jpg", "type": "image/jpeg", "size": 204533, "url": "https://files.example.com/...&X-Amz-Expires=900", "sourceUrl": "https://files.acme.example/roof.jpg?X-Amz-Signature=...", "copied": true }],
    "externalRefs": { "acme": "msg_1001", "acmeJob": "job_900", "acmeVisit": "visit_3" }, "caseRef": "job_900", "siteId": 312,
    "createdAt": "2026-10-01T12:00:00.000Z", "receivedAt": "2026-10-12T15:30:00.000Z", "editedAt": null, "deletedAt": null
  }
}

DMs and groups outside a job: POST /v1/office/external-threads/{threadKey}/messages, same body without threadKey.

#Idempotency, edits and deletes

  • The same externalRefs.acme again updates that message in place (body, author, channel, times, files); nothing is duplicated. The same id on another job or thread is 409 ref_in_use. A file link the message already had keeps its copy (it isn't fetched again).
  • PATCH /v1/office/external-messages/{id} edits one of your messages: any of body, attachments, channel, author, and editedAt (default now).
  • DELETE /v1/office/external-messages/{id} (optionally ?deletedAt=) leaves a tombstone: body and files are removed (copies in CrewLink's storage are deleted), deletedAt is set, and the row stays, so a replay of the same id can never bring it back (it answers unchanged). Deleting again is harmless; editing a tombstone is 409 deleted.
  • Your platform can't edit or delete the office's replies (409 not_mirrored); their author can, in CrewLink.
  • An Idempotency-Key header works as on every write, but the message id already makes retries safe.

#Backfill

POST /v1/office/external-messages/backfill. Up to 500 messages per call (501 or more is 422 invalid_request), request body up to 16 MB (413 beyond), 30 calls a minute per platform key (429 rate_limited with Retry-After), on top of the key's 1,200 a minute. Body:

json
{
  "messages": [
    { "jobExternalRef": "appt_88213", "channel": "tech", "body": "Panel is in the garage.", "author": { "externalId": "usr_77", "displayName": "Raj Patel", "role": "technician" }, "externalRefs": { "acme": "msg_0001", "acmeJob": "job_900" }, "createdAt": "2025-03-03T10:00:00Z" },
    { "jobId": 4568, "channel": "client", "body": "", "author": { "displayName": "Hana Okafor" }, "externalRefs": { "acme": "msg_0002" }, "createdAt": "2025-02-02T10:00:00Z", "deletedAt": "2025-02-03T10:00:00Z" },
    { "threadKey": "dm_usr412_usr77", "channel": "dm", "body": "Thanks!", "author": { "displayName": "Raj Patel" }, "externalRefs": { "acme": "msg_0003" }, "createdAt": "2025-01-15T10:00:00Z" }
  ]
}

Each item is the message body above plus where it goes: jobId, or jobExternalRef (and jobExternalSource, default acme), or threadKey alone for a thread outside a job. Send them in any order with their original createdAt: reads always order by createdAt, then id. Each is stored like a single POST (idempotent by externalRefs.acme), so a backfill can be re-run or resumed safely, and one bad item doesn't stop the rest. Linked files in a backfill are copied by the background job within a few minutes, not during the call. Answer 200:

json
{ "created": 498, "updated": 0, "unchanged": 0, "failed": 2,
  "results": [{ "index": 0, "status": "created", "id": 5521, "error": null, "message": null }, { "index": 7, "status": "error", "id": null, "error": "not_found", "message": "No job with that id." }] }

Nothing is notified for backfilled messages either.

#Files on mirrored messages

Your signed links expire, so CrewLink keeps its own copy:

  • A link (url, https): CrewLink fetches it server-side and stores it in its own storage, then serves it as a signed url good for 15 minutes (ask again for a fresh one). The link you sent is kept as sourceUrl; copied says whether CrewLink has the file. The fetch is SSRF-safe: https on port 443 or 8443 only, a host name (no IP literals or internal names) whose every address is public, connecting only to the addresses it checked, no redirects (a 3xx is a failure), 10 seconds, at most 20 MB, and the answer's Content-Type must be one CrewLink keeps (PDF, JPEG, PNG, HEIC, WebP, plain text, CSV, .docx, .xlsx; the stored type is the one the server sent).
  • When the copy fails (the host is down, a 4xx/5xx, a timeout), the message is stored anyway with copied: false and url = sourceUrl, and a background job tries again after 1, 5, 15, 60, 180, 360 and 720 minutes (8 tries in all). A file that is too big, of a type CrewLink doesn't keep, or on a refused address isn't retried. In a single POST or PATCH the copy is tried during the call; in a backfill it is left to the background job (within about a minute). Send a link that stays valid for at least a day so the retries can succeed.
  • An upload instead: POST /v1/office/external-messages/attachments { contentType, name, size } → { key, uploadUrl, expiresInSeconds }; PUT the bytes to uploadUrl within 15 minutes with the same Content-Type, then send { name, type, size, key } in the message. CrewLink checks the file then (422 bad_attachment when nothing is there, it is bigger than declared, or another type). Same types, up to 20 MB.

#Reading

GET /v1/office/jobs/{id}/external-messages, GET /v1/office/sites/{id}/external-messages (every visit at that site, each message with its jobId) and GET /v1/office/external-threads/{threadKey}/messages: { messages, nextCursor }, oldest first. Query: channel (comma list), limit (1 to 500, default 100), cursor (nextCursor), order (asc or desc), includeDeleted (true default: tombstones are listed with an empty body). Your platform reads its own messages and the office's replies on its jobs.

#The office's replies: external_message.created, .updated, .deleted

An office user answers in the job's Conversations tab in client, tech or internal (the company API key can too: POST /v1/office/jobs/{id}/external-messages with { channel, body }), and can edit (PATCH .../external-messages/{id} { body }) or delete (DELETE) their own answers there (the API key: the answers it wrote). CrewLink sends the event to the platform that owns the job's conversation (the one that booked the job, else the first one that mirrored messages into it; a platform mirroring into it later never gets the office's replies), and only there: never to the company's own endpoints or another platform, even with *. With no owning platform the office gets 409 no_platform. Subscribe with the event names or * on the platform-wide subscription (or a company-level one platform made); it needs the webhooks scope. The subscription's events list is honoured, including after PATCH .../webhooks/{id} { events }: drop the names (and *) and no more are queued.

json
{
  "id": 88231, "event": "external_message.created", "createdAt": "2026-10-12T15:31:00.000Z", "companyId": "7f0c...",
  "data": {
    "id": 5540, "source": "crewlink_office", "channel": "client",
    "jobId": 4568, "siteId": 312, "clientCompanyId": "c2a9...", "externalRef": "appt_88213", "externalSource": "acme", "externalRefs": { "acme": "appt_88213" },
    "caseRef": "job_900", "threadKey": null,
    "author": { "userId": "b1e4...", "personId": null, "displayName": "Delphine M.", "role": "dispatcher" },
    "body": "We can come Tuesday at 9.", "attachments": [],
    "createdAt": "2026-10-12T15:31:00.000Z", "editedAt": null, "deletedAt": null
  }
}
Field
id CrewLink's message id (ref: doesn't apply until you link your id)
source Always crewlink_office
channel client, tech or internal
jobId, siteId, clientCompanyId The CrewLink job, its site and its client company (the installer), or null
externalRef, externalSource, externalRefs The job's ids (yours under acme)
caseRef Your job or case id (from your messages' acmeJob, else the job's acme id)
threadKey Null (office replies are in the job's channels)
author userId (the office user, or null for the company API key), personId (that user's person on the schedule, or null), displayName (as the grant's people names say: "First L." unless full; the company's name for the API key), role (owner, dispatcher, field_manager, api)
body, attachments Markdown; attachments are always empty for office replies
createdAt, editedAt, deletedAt external_message.updated has the new body and editedAt; external_message.deleted has body: "" and deletedAt
  • Echo rules. Messages you mirror in never go back out. Store the reply in your platform under your own id and tell CrewLink which reply it was, instead of posting a copy: POST .../external-messages with { "channel": "client", "externalRefs": { "acme": "msg_2002" }, "crewlinkMessageId": 5540 } answers 200 with status: "linked" (and ref:msg_2002 then finds it). If you post it as a new message anyway it is stored as a second message; nothing is sent either way. Apply .updated and .deleted to the message you linked.
  • Office replies carry no attachments.

#Files on a job

Kinds proposal, photo, document
Types application/pdf, image/jpeg, image/png, image/heic, image/webp, text/plain, text/csv, .docx, .xlsx (their standard content types)
Size Up to 20 MB
Upload URL POST .../attachments { kind, name, contentType, bytes } → uploadUrl (PUT within 15 minutes, same Content-Type; storage refuses another), then POST .../attachments/{id}/uploaded, which checks the file in storage: 409 not_uploaded (nothing there yet: confirm again after the PUT), 422 upload_mismatch (bigger than bytes or 20 MB, or another type: the file and the pending attachment are removed)
Direct POST .../attachments/upload as multipart/form-data (file part; kind and optionally name as fields or query) or the raw bytes with the file's Content-Type and ?kind=&name=
Read GET .../attachments: uploaded files, each with a signed url good for 15 minutes (ask again for a fresh one). The job detail lists them too
Remove DELETE .../attachments/{id}: your platform removes only files your platform added (403 otherwise); the company removes any. The file is deleted from storage
Techs See them in the field app (GET /v1/tech/jobs/{id}/attachments) for jobs on their schedule
Event attachment.uploaded once per file

Errors: 409 not_uploaded, 413 too_large, 415 unsupported_type, 422 invalid_request (no file part, no kind or name), 422 upload_mismatch, 502 storage_unavailable (try again).

#Rate limits

  • 1,200 requests a minute per platform key, across all companies, in place of the per-IP limit. Over it: 429 rate_limited with Retry-After and X-RateLimit-* headers.
  • No hold cap and no per-IP limits on the keyed endpoints (/v1/office/availability, /v1/office/holds). Holds still expire after 5 minutes, so let unused ones lapse.
  • Polling the dispatch board or the live read: send If-None-Match and poll every 30 to 60 seconds per open view; 304s count too. dispatch.position is at most one per tech per 60 seconds, so pushed positions cost no requests.
  • Backfill has its own ceiling on top: 30 calls a minute per key (each up to 500 messages, body up to 16 MB).
  • Need more? Ask CrewLink before launch.

#Sandbox

On staging (https://go.crewlink.ai), CrewLink keeps:

Company acme-test, "Acme (test)", Chicago, 8 techs in two branches (North and West)
Services "Site survey" (90 min, 1 person, residential) and "Commercial site survey" (180 min, 2 people, commercial)
Platform acme "Acme", granted read, write and webhooks on acme-test and on the three demo companies (northline, sunline, ridgeline), which have months of history and a live day

#Errors

Every error is { "error": "<code>", "message": "<text you can show>" } (some add fields: reason, personId, jobId, conflictJobId, blockId, baseId, managedBy, managedByName). Branch on error. The OpenAPI spec (/v1/openapi.json) lists on every route the answers any call can give (400, 401, 403, 404, 422, 429, 500 as they apply) besides the route's own.

Status error When
400 company_required No X-CrewLink-Company
400 bad_request The body isn't valid JSON
401 unauthorized No key, a wrong or disabled key
403 scope The grant doesn't include the scope this needs
403 forbidden A route platforms never reach, or removing a file your platform didn't add
404 not_found Unknown id, another company's id, a company that hasn't granted your platform, a hold that isn't there or a wrong holdSecret
409 slot_taken That time can't be booked; reason says why
409 in_the_past The time has passed
409 duplicate_external_ref One of the external ids (source and id) is already on another of this company's jobs that isn't cancelled; jobId is it
409 already Already done (completing twice)
409 not_booked Completing a job that isn't booked
409 in_progress The first request with this Idempotency-Key is still running
409 trip_conflict, base_conflict A base overlaps a booked trip (tripId) or another base (baseId)
409 duplicate_external_ref (people, blocks, bases) That external id is already on a person (personId), block (blockId) or base (baseId) of this company; nothing was created
409 managed_by_integration Another integration owns this team's availability (managedBy, managedByName); see the managed flag
409 trip_base Removing a base a trip made
409 not_uploaded Confirming a file before anything was PUT to its upload URL
409 ref_in_use A mirrored message id already stored on another job or thread, or an office reply already linked to another id
409 deleted, not_mirrored Editing a deleted mirrored message; editing or deleting an office reply
403 not_yours (office) editing or deleting a message that isn't your own office reply
409 no_platform (office) answering in a job no platform owns
410 hold_expired The hold ran out
413 too_large A file over 20 MB
415 unsupported_type A file type CrewLink doesn't take
422 invalid_request Validation failed; message says which field
422 invalid_at at on complete is in the future or more than 30 days back
422 timezone_mismatch Weekly hours sent in a zone that isn't the tech's
422 idempotency_key_reused The key was used for a different request
422 reserved_source (public endpoints) an externalSource that belongs to a platform
422 invalid_url A webhook URL that isn't allowed
422 upload_mismatch The uploaded file is bigger than declared or the limit, or another type; it was removed
422 bad_attachment A mirrored message's file: neither or both of url and key, a key not uploaded first, or the upload doesn't match
422 empty A mirrored message with no body and no files
429 rate_limited Over 1,200 a minute; wait Retry-After
500 server_error Retry with backoff
502 storage_unavailable File storage didn't answer; retry

Changes to anything here are announced in the changelog.

CrewLink API · Reference · Changelog