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
- Keys, companies and scopes
- Endpoints you will use
- The booking flow, end to end
- Keeping techs in step (people, weekly hours, time off, bases)
- Owning technician availability from your system (your platform is the source of truth: shapes, idempotency, the managed flag, webhooks back and echoes)
- Dispatch board (the whole board for a day in one read, for your platform's admin app)
- Live positions and risk (positions, field statuses, ETAs and risk: a light poll and two webhooks; the route each tech is on)
- Location and directions (location trails for mileage and pay, fixes from your platform's app, turn-by-turn routes, distance matrix; privacy and retention)
- externalRef conventions
- Sites: one per property per client company (every visit ever made at a property)
- Idempotency and retries
- Webhooks
- Job objects
- Mirroring conversations (your platform's conversations in CrewLink, office only, nobody notified)
- Files on a job
- Rate limits
- Sandbox
- Errors
#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/opspages.
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.
# 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"]}'{ "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/platformslists the company's grants (revoked ones too).DELETE /v1/office/platforms/acme/grantrevokes. - A new grant sends
platform.grant.createdto your platform; a revoke sendsplatform.grant.revoked. After a revoke your platform's key gets404for 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
cancelledinGET /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 withnotify: falsewhatever 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: falseis kept when the hold is booked; the hold answer saysnotify. POST /v1/office/jobs/{id}/movenotify: falsesends nothing about the move and keeps the job quiet from then on;notify: trueturns its texts back on; left out, the job keeps what it had.POST /v1/office/jobs/{id}/cancelnotify: falseand.../completesilent: truealready existed for one action each.- Every job read (
GET /v1/office/jobs, the detail, intake) hashomeownerTexts:falsewhen any switch is off. The office's job drawer shows a "Homeowner texts off" badge. GET /v1/platform/grantsshows each grant'shomeownerMessaging.- 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'sreasonand itsintakeReasonon 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
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
curl -s $CREWLINK/v1/platform/grants -H "authorization: Bearer $PK"{
"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 }
]
}curl -s $CREWLINK/v1/office/catalog "${H[@]}"{
"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
curl -s -X POST $CREWLINK/v1/office/availability "${H[@]}" \
-d '{"serviceId": 41, "lat": 41.955, "lng": -87.675, "from": "2026-11-03", "days": 7}'{
"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:
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):
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
{
"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 withhomeownerMessaging: "off", as your platform's are). Then the homeowner hears nothing from CrewLink about this job, ever. techsnames are first name and last initial for a platform key, as everywhere your platform sees techs.manageTokenis the homeowner's manage-link token (they also get it by text); you don't need to store it.409 slot_takencomes with areason(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(withjobId).booking.createdfires withsource: "platform".
#4. Attach the proposal
Upload URL (bytes go straight to storage):
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):
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.pdfThe 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:
curl -s $CREWLINK/v1/office/jobs/1042 "${H[@]}"#6. Reschedule
Ask for times as if the job weren't there, then move it:
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).
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).
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).
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}]}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
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).
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)
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}'{ "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
# 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
# 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(oravailability) 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
externalRefslike jobs do: one id per source, each (source, id) unique in the company (409 duplicate_external_refwithpersonId). The shorthandexternalRefuses sourceacme. PATCH /v1/office/team/{id}{ "externalRefs": { "crewlink-v1": "P-7", "acme": null } }adds or replaces ids;nullremoves a source.- Your platform sees names as first name and last initial (
"Rosa D."), here and everywhere. emailandphoneare the tech's own (stored on the person;emaillower case,nullon 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/userswith theirpersonId), 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
timezoneis their own when set ("timezone": "America/New_York"onPOSTorPATCH /v1/office/team;nullonPATCHclears it), else their branch's, else the zone of their home (fromhomeLat/homeLng, an offline map), else the company's. Team reads show it withownTimezone(what was set). Zones with the same clock are one: a Louisville or Indianapolis tech readsAmerica/New_York. When a tech's zone changes (theirtimezone, a move to another zone, another branch), their booked jobs and time off keep their instants and are re-expressed on the new clock (rezonedin thePATCHanswer), andperson.updatedsays the newtimezone. - Radius: a tech without a branch is offered work within
maxTravelMilesof home, else the company'sdefaultTechRadiusMiles(PATCH /v1/office/settings, owner; shown inGET /v1/office/mecompany), else 40 km; on a base day, around the base. Team reads show it asserviceRadiusMiles. A branch's techs keep the branch's area.acme-crewhasdefaultTechRadiusMiles: 150. - An address answers on its own clock (above);
outsideAreaistruewhen 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,endand each visit's UTC are unchanged;timezone,localandendLocalchange, and so doesupdatedAt(pick them up withupdatedSince). No webhook is sent for it. - Importing old commitments:
POST /v1/office/jobswithsource: "import"andtechIdsbooks 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 withtechIds); their hours are not changed and the job readsoutsideHours: true. A tech already on another visit then is409 tech_busy(personId,conflictJobId); sendallowQueue: trueto have it queued instead. See office.md.
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
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, []]
}'{ "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 (
nullor[]), one window[start, end], or several[[start, end], ...], in minutes after local midnight in the tech's zone (timezoneonGET /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):
availabilityis every window, and nothing is offered in the gaps.hoursis each day's envelope (first start to last end), for code that reads one window a day.mergedis always[](kept for integrations that read it). The answer is always what is stored. timezoneis 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;
outsideHourslists booked job ids now outside the hours, for the office to move. - Sends
availability.updatedandperson.updated.
#Time off
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)
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" }
}'{ "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
fromtoto(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(withtripId) when a booked CrewLink trip covers any of those days: the trip's lodging already is the base.409 base_conflict(withbaseId) when another base does.- A trip booked in CrewLink writes its own base (
tripIdset,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.
{ "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):
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:
{
"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
}availabilityis the week exactly as stored and in the same shapePUT …/availabilitytakes: 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.hoursis kept for older code that reads one window a day: each day's envelope (first start to last end),nulloff. Never write it back as the week: with a lunch gap it would fill the gap.availabilityManagedByis who owns this person's availability (acme),nullwhen it's managed in CrewLink.
#Weekly hours
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:
{
"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
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"
}'{ "id": 311, "personId": 57, "start": "2026-12-01T08:00", "end": "2026-12-01T17:00", "timezone": "America/Chicago", "reason": "PTO", "conflicts": [], "externalRefs": { "acme": "pto-9" } }startandendare 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_refwith"blockId": 311and creates nothing. Treat it as success and keepblockId. - Changing a block: there is no PATCH. Delete it and add the new one (with the same
externalRefonce the old one is gone). conflictslists 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 withDELETE /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 (fromtotoinclusive). A longer range, ortobeforefrom, is422 invalid_requestwith"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 previousto) and de-duplicate byid(a block spanning two windows comes back in both). - Blocks with
sourcegoogleoroutlookare 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
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/homeLnggo together; the day starts and ends there from now on. Sendaddresswith them.maxTravelMiles(1 to 3000,nullremoves it): the tech is never offered work farther than this from home (or that day's base). Without it, the company'sdefaultTechRadiusMilesapplies (150 foracme-crew). Team reads show the result asserviceRadiusMiles.timezone(IANA,nullfollows 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
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" }
}'{ "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
fromtoto(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
externalRefsid already on a base of this company is409 duplicate_external_refwith"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_basefor 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_integrationwhen 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}withhours,availability,homeLat/homeLng,address,timezoneormaxTravelMiles(other fields, such asphoneorskills, still work);POSTandDELETE /v1/office/blocks;POSTandDELETE /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": trueto the body (?force=trueon theDELETEs). It goes through, is recorded (GET /v1/office/availability-managementoverrides) and its webhooks say"forced": truewithsourceofficeorapi. Anyone else sendingforcegets403 forbidden. When you seeforced: 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;nullfollows the company. Team reads show the result (availabilityManagedBy) and the person's own setting (ownAvailabilityManagedBy). Your key may set its own slug ornull, and can't undo an owner'screwlinkor 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:
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 CrewLinkA 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 |
{ "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 } }{ "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.
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
200carries a weakETag(andCache-Control: private, no-cache,Vary: Authorization, X-CrewLink-Company). Keep it per company and query, and send it back asIf-None-Match: an unchanged board answers304with no body. The tag covers the whole body exceptgeneratedAt, 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 sayspeopleNames: "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
locationby the grant (the position rule): withpeopleNames: "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. Otherwisenull; never off the clock. - Only the company named in
X-CrewLink-Company, and only if it grantedread(orwrite). Any other company is404.
#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):
{
"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).
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{
"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.
curl -s "$CREWLINK/v1/office/dispatch/routes" \
-H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew" \
-H 'If-None-Match: W/"Hx2c..."'{
"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.mapStyleis alwaysnullfor 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 ason_time),reason,basis,start,eta,minutesLate, plus the job'sexternalRef,externalSource,externalRefs. Arrivals arejob.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'slocationRetentionDays(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:
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).
curl -s "$CREWLINK/v1/office/team/1/locations?date=2026-10-12" \
-H "Authorization: Bearer $PLATFORM_KEY" -H "X-CrewLink-Company: acme-crew"{
"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).
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}
]}'{ "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 optionalaccuracy(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, anatthat isn't an instant) is422 invalid_requestand nothing is stored. - Per fix: an
atmore than 7 days old (too_old), more than a minute in the future (in_future), or0,0(invalid_point) is listed inrejectedwith itsindex; 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
offClockand 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 aduplicate, so resending a batch after a timeout is safe (anIdempotency-Keyalso 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 carrysource: "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.
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.
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}'{ "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]] }originsanddestinations: 1 to 25 each (422beyond). Cells are[origin][destination];nullwhere there is no road route.durationsSecondsis free flow.planned: trueaddsplannedSeconds: the drive CrewLink's planning allows leaving atdepartAt(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>" }.
{ "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 shorthandexternalRef(+ optionalexternalSource), which writes one entry. The shorthand's source defaults toacme(your platform slug), so{"externalRef": "appt_88213"}is{"externalRefs": {"acme": "appt_88213"}}. Both may be sent if they agree (422ifexternalRefandexternalRefs[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_refwith that job'sjobId, and nothing is booked. That makes a lost response safe: retry with the same ids (or the sameIdempotency-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 thatsiteId) may carry it too. Per-visit ids (a source ending inVisit, such asacmeVisit, andcrewlink-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:
201with a newjobId. 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 anacmeid, or every job with that id under any source). Every job that has the id is listed, a cancelled one too; withexternalRefand nosort, the job that isn't cancelled comes first, then the newest filed, sojobs[0]is the job the id points to now (all cancelled: the most recent one). - Every read returns
externalRefs(all of them) plusexternalRef/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, andPOST /v1/office/requestswith your key, which also takesclientCompanyId) 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
acmeid maps to one site. On a live booking (not an import), a visit carrying theacmeid 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: theiracmeids 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
acmeid: a revisit service may carry it; any other service needssiteId(from the first visit'ssiteId); without it the sameacmeid is still409 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.
#Client share links
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.
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,holdSecretand 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
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": ["*"]}'{ "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
webhooksscope, now and later, plusplatform.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:
httpson 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.rescheduledwithhistory: true), an unassign (booking.rescheduledwithunassigned: true) and a silent completion (job.completedwithsilent: true) are sent only to subscriptions withhistoryEvents: 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 isfalsefor every existing subscription and for new ones unlessPOSTsends"historyEvents": true. When your handler is ready (it should treathistory: trueas a record of the past, never a client notice), turn it on:bashcurl -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/webhooksshows it. It also works on a company-level endpoint your platform made (or usePATCH /v1/office/webhooks/{id}withX-CrewLink-Company). Every other event is unchanged either way. Details: webhooks.md.
#What a delivery looks like
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"}}}companyIdsays which company the event is about; match it withX-CrewLink-Companyids fromGET /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 comparecreatedAt(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?
curl -s "$CREWLINK/v1/platform/webhooks/$SUB/deliveries?jobId=5679" -H "authorization: Bearer $PK"{
"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.bookingIdordata.requestId),status(pending,delivered,failed),limit(1 to 200, default 50),before(thenextof the previous page). Newest first. lastStatusCodeis what your server answered;nullwith alastErrorsuch asRefused: resolves to a non-public addressorTimed outmeans no answer came back. A3xxis 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}/deliverieswithX-CrewLink-Company, or this route). POST /v1/platform/webhooks/{id}/deliveries/{deliveryId}/retryqueues one again now (sameidand 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.
// 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 (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:
{
"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
{
"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.
{
"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 withstatus=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/localare null forwaiting,unscheduledanddeclined.endis the longest visit's booked end on the first day.local,endLocaland visit times are the wall clock intimezone, the zone of the job's address (storestart, showlocal).techis the first assigned tech (by id);techsis everyone.outsideHours:truewhen 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.timelinekinds:created,en_route,arrived,checkin,done,note,attention,attention_cleared;byistech,office(anyone with an office or platform key) orsystem.updatedAtchanges 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.)siteIdandsiteVisit({ 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);nulluntil 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, andmessage.*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,techorinternalchannel), edits that answer or deletes it, CrewLink sendsexternal_message.created,.updatedor.deletedto 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):
{
"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.
{
"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.acmeagain updates that message in place (body, author, channel, times, files); nothing is duplicated. The same id on another job or thread is409 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 ofbody,attachments,channel,author, andeditedAt(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),deletedAtis set, and the row stays, so a replay of the same id can never bring it back (it answersunchanged). Deleting again is harmless; editing a tombstone is409 deleted.- Your platform can't edit or delete the office's replies (
409 not_mirrored); their author can, in CrewLink. - An
Idempotency-Keyheader 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:
{
"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:
{ "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 signedurlgood for 15 minutes (ask again for a fresh one). The link you sent is kept assourceUrl;copiedsays 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 (a3xxis a failure), 10 seconds, at most 20 MB, and the answer'sContent-Typemust 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 withcopied: falseandurl=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 touploadUrlwithin 15 minutes with the sameContent-Type, then send{ name, type, size, key }in the message. CrewLink checks the file then (422 bad_attachmentwhen 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.
{
"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-messageswith{ "channel": "client", "externalRefs": { "acme": "msg_2002" }, "crewlinkMessageId": 5540 }answers200withstatus: "linked"(andref:msg_2002then finds it). If you post it as a new message anyway it is stored as a second message; nothing is sent either way. Apply.updatedand.deletedto 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_limitedwithRetry-AfterandX-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-Matchand poll every 30 to 60 seconds per open view;304s count too.dispatch.positionis 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.