Changelog
Changes to the CrewLink API, newest first. Breaking changes are marked Breaking and say what to do.
#2026-10-13 (sixty-sixth update: cancelling a done job, daylight-only services, taps on the day, not-closed-out webhooks)
One migration (two columns). Nothing is removed; two fields are added to reads and two error codes are new.
- New: a job that is done can be cancelled (POST /v1/office/jobs/{id}/cancel) by owners and dispatchers (in their scope), the company API key and platform keys with
write. It readsjobStatus: "cancelled"with the newcancelledAfterDone: true(job reads;doneAtis kept).booking.cancelledcarrieswasDone: true. Nobody is texted unlessnotify: trueis sent (a done job's default isfalse). KPIs and plan vs actual stop counting it. Each tech's pay for it is taken back and listed in the answer'spayReversed: removed from a pay week that is still open, or a negative adjustmentJob #<id> cancelled after completionin the open week when its week is closed or paid.GET /v1/office/pay/linesalso takesjobId(every line for a job, any week). - New: services can be daylight only (
daylightOnlyon the service object, owners only; also inGET /v1/public/{slug}andGET /v1/office/catalog). Only starts at or after local sunrise whose visit ends by local sunset at the address on that date are offered (availability, first-available, the intake queue's suggestions, pick-a-new-time choices), and holds, bookings and moves outside it get409 outside_daylightwith a message giving that day's sunrise, sunset and latest start. The client share link routes follow it too (/v1/public/share/{token}/availability,/holds, and/bookings, which checks the held time again), andGET /v1/public/share/{token}services carrydaylightOnly. Owners can override withallowOutsideHours: trueonPOST /v1/office/jobsor a move (dispatchers:403 owner_only); imports and history are exempt. - Behavior change: the field app's stage taps and check-ins only work for today's and yesterday's jobs:
409 not_todayfor a job on a later day or more than a day ago (the job's own time zone), with a message to show. Office and integration completions (POST /v1/office/jobs/{id}/complete) are unaffected. The CrewLink app drops a queued tap that gets it and tells the tech why. - Behavior change: a job not closed out now sends
job.needs_attention(source: "system",by: "closeout") even when the job isnotify: false:notifycontrols homeowner texts and emails, never webhooks. Still no homeowner text and no tech push. (A kept double-booking undernotify: falsestays quiet, as before.)
#2026-10-13 (sixty-fifth update: client share links)
New: booking links a client company's sales reps open with no account (guide). Nothing is removed; every change to existing objects is an added field. One migration (share_links, and jobs.share_link_id / jobs.booked_by, platforms.share_link_host / share_link_mail_from).
- Managing links (owner or company-scope dispatcher; platform keys with
write, reads withread):GET/POST /v1/office/clients/{clientId}/share-links,GET/PATCH/DELETE /v1/office/clients/{clientId}/share-links/{linkId},POST .../{linkId}/rotate. One general link per client plus optional per-rep links; label, services, daily cap (default 25), expiry, pause, rotate, revoke; an audit trail per link. Platform keys may now write these routes (the rest of/v1/office/clientsstays off limits to them). - The page at
/r/<token>(the client's name only: no CrewLink, no tech names; noindex, no referrer) and its public routes:GET /v1/public/share/{token},POST /v1/public/share/{token}/challenge,/availability,/holds,/bookings,/requests.GET /v1/public/geocodetakesshare=<token>in place ofslug. - Abuse controls: per-IP and per-link rate limits, live holds per link, a daily cap per link (
429 daily_cap), a hidden field (400 rejected), a signed proof-of-work and timing check (403 proof_invalid,proof_too_fast,proof_used,proof_expired), optional Cloudflare Turnstile (SHARE_LINK_TURNSTILE_SITE_KEY,SHARE_LINK_TURNSTILE_SECRET), phone and email validation (422 invalid_phone,rep_required). - Added fields: jobs read
source: "share_link",shareLinkIdandbookedBy({ repName, repEmail };filedByreads "Booked by Rita Rep via link · Globex Solar");booking.created,request.createdand every job webhook carryshareLinkIdandbookedBy(nullfor other jobs); a site's timeline entry for such a visit hasby: "rep";GET /v1/office/clients/{id}jobs havebookedBy;sourcefilters takeshare_link. - Your own domain:
shareLinkHostandshareLinkMailFromonPATCH /v1/office/settings(and in its answer), a platform-wide host (cli.ts platform-share-host),SHARE_LINK_HOSTSon the server. Link objects answerurl(on that host when set) andfallbackUrl(on go.crewlink.ai). A share host serves only the share page and its routes. Your own domain. - Emails to the rep and the client's owners after a booking or request, in the client's name (the sender name; never CrewLink).
- Sign-up: a company named like a public route word (
share,geocode,plans,invoices,reviews,offers,invitations) gets-coon its slug.
#2026-10-13 (docs: public guides)
No API change. These guides are public at /docs/guides (this changelog at /docs/guides/changelog), next to the reference at /docs, with search, heading links, light, blue and dark, and a sitemap at /docs/sitemap.xml. The reference's header and intro link to them; the old guides link, which never resolved, is gone. A guide's URL is its file name without .md (platforms.md#people-names is /docs/guides/platforms#people-names).
#2026-10-13 (sixty-fourth update: honest invoice sends, merged sites and scope)
Nothing is removed; one field is added and one read is narrowed.
- Fix: people with a
teamorownscope no longer read other people's merged sites (GET /v1/office/sites/{id}andGET /v1/office/sites?siteStatus=mergedshowed their customer and address): a merged site is theirs only when the site it was merged into holds one of their visits. - Fix:
POST /v1/office/usersfrom a CrewLink staff act-as session no longer returns the new user'stoken(tokenis now optional in the answer; it is always there for owners and the company API key). POST /v1/office/invoices/{id}/sendanswerstexted: whether the text with the invoice link was queued.falsewhen the job's homeowner texts are off (notify: false, the company'shomeownerMessaging, or the booking platform's grant) or the number can't be texted; the invoice is still sent. The web app no longer says "Invoice texted" when nothing went out.
#2026-10-13 (sixty-third update: authorization QA: positions, imports, availability, directions budgets)
From the authorization and privacy re-audit (SECURITY.md). No migration.
- Behavior change: a tech's position is never shown off the clock to anyone, the company's own office and keys included (was: the last fix on the tech's today).
GET /v1/office/dispatch,GET /v1/office/dispatch/liveandGET /v1/office/livenow showlocation/positionto company callers only while the tech is on the clock, now and when the fix was taken (30 minutes before their first window or visit to 30 minutes after their last), like a full-names platform grant and likeGET /v1/office/dispatch/routesalready did. Who sees a position. - Behavior change: on
POST /v1/office/jobs,source: "import"andallowPast: trueare owners only (an owner's sign-in, the company API key, an act-as session, a platform key withwrite); dispatchers and field managers get403 owner_only, as they already did forallowOverlapandallowPaston a move. An import may be double-booked later by anyone who may move it, so filing one was a way around that rule. - Behavior change: a platform key can't put one person under itself (
PATCH /v1/office/team/{id}availabilityManagedBy) while another integration owns that person's availability through the company:409 managed_by_integration. Owners still can. - Behavior change:
POST /v1/office/route(60 a minute) andPOST /v1/office/route-matrix(30 a minute) are now limited per company, shared by all of its users and keys (was per user or key), and per platform for platform keys (unchanged). - Behavior change: an office reply on a job no platform booked goes to the first platform that mirrored messages into it (was the latest), so another platform with
writeon the company can't redirect the office's replies by mirroring one message. - Linked files of mirrored messages are fetched with a fresh connection each time,
Accept-Encoding: identity, and a response cut short is a failed copy. The address guard (webhooks and file copies) also refuses 6to4 (2002::/16) around a non-public IPv4 address, Teredo (2001::/32), local-use NAT64 (64:ff9b:1::/48), IPv4-translated (::ffff:0:a.b.c.d) and discard-only (100::/64) addresses.
#2026-10-13 (sixty-second update: the company API key edits its own conversation replies; platform contract checked end to end)
- Fix: the company API key could answer in a job's mirrored conversation (
POST /v1/office/jobs/{id}/external-messages{ channel, body }) but not edit or delete that answer (403 not_yours). It now may change the replies it wrote (PATCH/DELETE /v1/office/external-messages/{id}), which sendexternal_message.updated/.deletedto the owning platform as a user's do. It still can't change a signed-in user's reply, and nobody else can change the key's. - Docs: in platforms.md,
job.completedfor a silent completion is sent withsilent: trueto subscriptions withhistoryEvents: true(the table still said a silent completion sends no event). The OpenAPI description ofPOST /v1/office/jobs/{id}/moveno longer saysallowOverlapandallowPastare for imports only (owners, including platform keys withwrite, may use them on any job, as the rest of it says). tools/qa/contract-live.mjsnow runs the whole platform contract against a live server: every endpoint in the platform guide's table (it fails when one isn't called), sites (grouping, merge, split,siteIdon jobs and every job event,site.*payloads), mirrored conversations (all endpoints,ref:addressing, threads, backfill, files, the office's replies and their events, echo rules), weekly hours and the managed flag (an owner'sforce,forcedin webhooks), blocks (92-day reads), bases, location trails and fixes (thinning, duplicates, off-the-clock drops,dispatch.positiononly when named), directions and the matrix, the dispatch reads (ETag /304), webhooks read back through the delivery log (historyEventsopt-in,PATCHof events, retry), every kind of move, idempotency,peopleNameson a short grant, and{ error, message }on every error.
#2026-10-13 (sixty-first update: the Enterprise call link)
- The default
enterpriseCallUrl(GET /v1/public/plans,422 contact_saleson sign-up and plan changes) is nowhttps://crewlink.ai/#walkthrough. It washttps://crewlink.ai/#demo, an anchor the site doesn't have, so "Book a call" landed at the top of the home page. A server withENTERPRISE_CALL_URLset keeps its own link.
#2026-10-13 (sixtieth update: scheduling and sites fixes from deep QA)
No fields are added or removed.
Fixed: time off over several days (one
POST /v1/office/blocksfrom Tuesday to Friday, or a multi-day busy event from a linked calendar) now keeps every one of those days clear. Before, only the day it started on was blocked, and none when that was a day off or before the days asked for, so availability, the booking page, first-available and auto-assigned bookings could put a job on a tech's vacation. Bookings that land there now answer409 slot_takenwithreason: time_off. Jobs already booked inside such a block are not moved;GET /v1/office/blockslists the blocks to check.Fixed: overdue for companies whose jobs are on several clocks.
GET /v1/office/jobs?tab=overdue(andtab=scheduled), the counts and risks ofGET /v1/office/intake, and the report tiles compared every job's end with now on the company's clock; they now use each job's own zone, as each row'soverdueflag and the dispatch board already did. Before, on a Chicago company, a Los Angeles job read overdue two hours before it ended and a New York one an hour late.Fixed: a person's
maxTravelMilesis measured from where their day starts (startsFrom): someone whose home isn't a real place (0,0) and starts from the branch was measured from 0,0 and never offered anything.Changed:
GET /v1/office/kpis(and the KPI CSV, PDF and weekly email) giveprior: nullanddelta: nullwhen the prior period starts before the company's Count KPIs from date, instead of comparing with a partial or zero period.Fixed: sites at Florida addresses: the state part
FL("…, Miami, FL 33101") was read as a floor, so the same house written with and without that comma got two sites.cli.ts sites-backfillre-keys the sites the old rule made and folds the duplicates together (silently), and now also regroups the same street line in a second town.Existing sites are re-keyed once after this release: same rules as the backfill, silent (no webhooks, jobs'
updatedAtunchanged). Florida addresses split by the old rule and a street line repeated in a second town fold together; absorbed sites stay readable asmerged.
#2026-10-13 (fifty-ninth update: jobs not closed out are flagged)
Nothing is removed and no endpoint changes; one more thing raises the existing needs-attention flag. Guide: office.md.
- Behavior change: every 15 minutes, a booked job that isn't marked done 3 hours after its last visit's scheduled end, or by 21:00 that day when that comes sooner (the job's own time zone), is flagged needs attention with the reason
Not closed out: still <stage> since <time>(not started since 10:00,on the way since 09:40,on site since 13:05). Once per job: clearing it is final, and a job already flagged for something else keeps that flag. Imports, history written withallowPast, and visits that ended more than two days ago are left out. - It shows where every flag does (Dispatch risks, the Needs attention tab, the job) and sends the existing
job.needs_attentionevent withsource: "system",by: "closeout", except for jobs withnotify: false, which get the flag and timeline entry only (as a kept double-booking does). Platforms: expect this event for jobs your techs didn't close out in the app; nothing to change if you already handlejob.needs_attention. No homeowner text, no tech push. - Webhook docs:
job.needs_attention'ssourcelists every value (api,office,tech,system) andbythe system ones (closeout,recurring).
#2026-10-12 (fifty-eighth update: mirrored conversations from platforms)
Nothing is removed or changed; endpoints, events and a limit are added. Guide: platforms.md.
- New: a platform mirrors its conversations about a job into CrewLink, as an office-only log separate from CrewLink's messaging:
POST/GET /v1/office/jobs/{id}/external-messages({id}may beref:<your id>),POST/GET /v1/office/external-threads/{threadKey}/messages(DMs and groups outside a job),PATCH/DELETE /v1/office/external-messages/{id}({id}may beref:<your message id>; delete leaves a tombstone),POST /v1/office/external-messages/backfill(up to 500 messages a call, original timestamps) andPOST /v1/office/external-messages/attachments(upload a file). Idempotent by the platform's message id (externalRefs.acme). Mirrored messages notify nobody (no text, email, push, in-app message or webhook), whatever the flags. - Files linked with an https
urlare copied into CrewLink's storage (an SSRF-guarded fetch: public hosts only, no redirects, 10 s, 20 MB, allow-listed types) and served as 15-minute signed links; the link sent stays assourceUrl, andcopiedsays whether CrewLink has the file. A failed copy keeps the link and is retried in the background (8 tries over about a day); backfilled links are copied by that job within minutes. - Who reads them: office users within their scope (DMs and groups: company scope only) and the platform that wrote them. Never techs, client companies, another company or another platform.
- New: the office answers in a job's mirrored conversation (
client,techorinternal) withPOST /v1/office/jobs/{id}/external-messages{ channel, body }(409 no_platformwhen no platform owns it), and its author edits (PATCH,{ body }) or deletes (DELETE) it (403 not_yoursfor anyone else). - New events
external_message.created,external_message.updated,external_message.deleted: sent only to the owning platform's endpoints, withclientCompanyId, the job's ids,caseRef,threadKeyandauthor(userId,personId,displayNameas the grant'speopleNamessays,role). Mirrored messages never echo back; a platform links its own id to a reply withcrewlinkMessageId. - New limit: 30 backfill calls a minute per platform key, on top of the 1,200; backfill bodies up to 16 MB.
- Sites: every message on a job carries its
siteId(and so do theexternal_message.*events);GET /v1/office/sites/{id}/external-messageslists every visit's.caseRefstays for your platform. - Web: a Conversations tab on the job (Dispatch and Jobs drawers) and on the site card (Jobs › Sites, read-only there) for the office, grouped by channel, with an Internal badge, a reply box, and edit and delete for your own replies.
#2026-10-13 (fifty-seventh update: change a subscription's events in place)
PATCH /v1/platform/webhooks/{id}andPATCH /v1/office/webhooks/{id}now also takeevents, which replaces the endpoint's event list. The id, URL and signing secret stay the same.historyEventsis now optional; send either field or both. Use it to adddispatch.positionorjob.risk_changed(["*", "dispatch.position", "job.risk_changed"]) without creating a new subscription.
#2026-10-12 (fifty-sixth update: location trails, fixes from a platform's app, directions and a distance matrix for platforms)
For a platform ("give your platform API access to CrewLink's tech location and navigation services"). New: Location and directions. Endpoints, one setting and one table are added; nothing is removed. The field app's location fixes are now also kept as a trail (marked Behavior change). One migration (tech_location_fixes).
- New:
GET /v1/office/team/{id}/locations(date, orfrom/toat most 24 hours apart): a tech's on-the-clock fixes (at,lat,lng,accuracy,heading,speed,source) and asummary: road distance (distanceMeters,miles,distanceMethodmap_matched/segments/none),stops(3+ minutes within 75 m, matched to a visit within 150 m), per-visitarrivedAt/departedAt/dwellMinutes/plannedOrder/actualOrder, andplannedOrder/actualOrder. Owners and company-scope dispatchers; platform keys withreadon a grant withpeopleNames: "full"(403on any other grant). - New:
POST /v1/office/team/{id}/locations(platform keys withwrite): up to 500 fixes from the platform's own tech app, each within the last 7 days and not in the future (rejectedper fix:too_old,in_future,invalid_point), deduplicated by (tech,at). On-the-clock fixes join the trail withsourcethe platform's slug and the newest becomes the live position (dispatch board, live read,dispatch.position); off-the-clock fixes are dropped (offClock). The integrator must hold the tech's location consent. - New:
POST /v1/office/route(read):fromtotoorjobId, up to 23waypoints,departAt,profile: "driving": the field app's route shape (GeoJSON line, distance, duration,eta, steps with instruction, maneuver and voice, lanes and posted limits when known). Mapbox with traffic, else OSRM; 60 a minute per caller.mapStyle(publicpk.token only) only withincludeMapStyle: true. - New:
POST /v1/office/route-matrix(read): roaddistancesMeters, free-flowdurationsSecondsandmilesfor up to 25 x 25 points (OSRM), and withplanned: truethe drive CrewLink's planning allows (plannedSeconds). For mileage and pay; 30 a minute per caller. - New setting:
locationRetentionDays(7 to 365, default 90) onPATCH /v1/office/settings, in its answer and inGET /v1/office/mecompany. Trail fixes older than it are deleted by the hourly retention job. - Behavior change:
POST /v1/tech/locationand the stage taps' positions are, when taken on the clock, also added to the tech's location trail (thinned to one per 25 m or 60 s, at most one per 10 s). Off the clock nothing is added. The last known position is unchanged (30 days). Before this update CrewLink kept no trail. - Docs: how to subscribe to
dispatch.positionby name (example), and what your platform's clients may see (status only; positions rounded to 100 m or coarser; never the trail).
#2026-10-12 (fifty-fifth update: the route each tech is on)
For the office's new live Map on Dispatch and for your platform's own admin map ("admins should see the live location of the technician and the route they are on"). One endpoint is added; nothing else changes. No migration.
- New:
GET /v1/office/dispatch/routes(owners, dispatchers, field managers; platform keys withread, see Routes): per working tech,positionandheading,status,positionState(live,stale,none= location off,off_clock,hidden),navigating(following CrewLink's in-app directions), theroutethey are on as a GeoJSON LineString (the navigation route their phone follows, else the planned driving route through their remaining stops),progressMeters,legs[](distanceMeters,durationSeconds,eta,minutesLate,provider) andstops[]. Filtersdate,branch,client,techIds; ETag/304; poll every 15 to 30 s. - Positions on it follow the platform rules and, unlike the other reads, are hidden off the clock from the company's own office too. People with a
teamorownscope see only techs with a stop of theirs (it is on their allowed list). - Routing is cached and capped (6 provider calls per read, 60 a minute); a leg not routed yet is a straight line (
provider: "straight") until a later poll.
#2026-10-13 (fifty-fourth update: sites, one per property per client company)
Nothing is removed and nothing changes meaning: visits (jobId, bookingId), their externalRefs and the booking.* and job.* webhooks are as they were. Fields, endpoints and events are added, and one 409 becomes allowed (marked Behavior change).
- New: sites. A site is one property (address) for one client company (or your own work), holding every visit ever made there. See Sites and, for your platform, platforms.md. Visits go to a site by
siteId, then a shared external id on a booking that isn't an import (such asacme), then the normalized address and client company; your own work joins the one client's site at its address. Two client companies never share a site. - New endpoints:
GET /v1/office/sites,GET /v1/office/sites/{id}(with the timeline),PATCH /v1/office/sites/{id}(the site's ownexternalRefs),POST /v1/office/sites/{id}/mergeandPOST /v1/office/sites/{id}/split(audited; platform keys needwrite). - New on job reads (
GET /v1/office/jobs,/jobs/{id}, intake):siteIdandsiteVisit({ number, of, revisit }: "Visit 3 at this site"). New filtersiteIdonGET /v1/office/jobs. - New on
POST /v1/office/jobs: optionalsiteId(404,422 different_client); the201and202answers carrysiteId. - New on every webhook about a job (
booking.*,job.*,request.created,attachment.uploaded, ...):siteId. - New webhooks:
site.created,site.updated,site.merged. Endpoints subscribed to*get them too, as documented for new events: a receiver should ignore events it doesn't use. - Behavior change: an external id already on a live visit is accepted (it was
409 duplicate_external_ref) when the new visit joins that visit's site: a revisit service, or one sent with thatsiteId. Everything else still answers409. Per-visit ids (sources ending inVisit, such asacmeVisit, andcrewlink-v1) are never shared. - New on
GET /v1/office/clients:sitesandactiveSites(the client's properties);jobsstill counts visits. - New on
GET /v1/tech/daystops:siteVisit(for "Visit 3 at this site"; null when the stop is its site's only visit). - Existing jobs are placed at sites after the release (
cli.ts sites-backfill): no webhooks, and theirupdatedAtis left as it was.
#2026-10-12 (fifty-third update: history emissions are opt-in per webhook endpoint)
For a platform, whose handler isn't ready for the history emissions the fifty-second update added. One field and two endpoints are added; four emissions now reach only endpoints that ask for them (marked Behavior change). One migration (webhook_endpoints.history_events, default false).
- New:
historyEvents(boolean, defaultfalse) on every webhook endpoint: onPOST /v1/office/webhooksandPOST /v1/platform/webhooks(optional), and in their answers and lists. - New:
PATCH /v1/office/webhooks/{id}andPATCH /v1/platform/webhooks/{id}with{ "historyEvents": true | false }. Office: owners, or a platform key on an endpoint it made. Platform: any endpoint the platform made, its platform-wide subscription included. - Behavior change: the history emissions,
booking.created/booking.rescheduledwithhistory: true(a past start or a move to a past time written as history),booking.rescheduledwithunassigned: true(unassign) andjob.completedwithsilent: true(silent completion), are sent only to endpoints withhistoryEvents: true. Every existing endpoint starts withfalse, so none of them gets these until it is turned on. Payloads are unchanged for endpoints that opt in; every other event is unchanged for everyone. What each office path sends marks the rows that need it.
#2026-10-12 (fifty-second update: owners may double-book, every move sends its webhook, unassign)
For a platform ("technician changes mirror both ways, and admins may double-book"). Fields and one error are added; four answers change (marked Behavior change). No migration.
- New: on
POST /v1/office/jobs/{id}/move, owners (an owner's sign-in, the company API key, an act-as session, a platform key withwrite) may sendallowOverlap(withtechIds) andallowPaston any job, not only imports. A skipped overlap answersdoubleBookedand flags the job needs attentionDouble-booked with #N(quiet withnotify: false); working hours, the on-call reserve, time off, skills, the team and the test split are still checked. A past start is written as history (history: true, overdue until completed). The office's Move panel offers owners Book anyway (double-book) and Record past time when a move is refused for those reasons. - Behavior change: dispatchers and field managers sending
allowOverlaporallowPaston a job that isn't an import get403 owner_only(was422 invalid_request). Owners no longer get422for them. Imports are unchanged for everyone. - New: unassign with
techIds: []on a move (owners, platforms, dispatchers): the job keeps its time, goes back to the intake queue asunscheduledwithintakeReason: "Unassigned", its visits are removed, no homeowner text, andbooking.rescheduledgoes out withtechs: [],status: "unscheduled",unassigned: true,fromandrequested({ start, local }). The answer is{ jobId, status: "unscheduled", start, local, timezone, techs: [], unassigned: true }. The job drawer has an Unassign button. - Behavior change: a move written as history (a past time: an import, or an owner's
allowPast) now always sendsbooking.rescheduled(orbooking.createdfrom the queue) with itstechsandhistory: true; withnotify: falseit sent nothing.notifyonly controls homeowner texts, never webhooks. - Behavior change:
POST /v1/office/jobswriting a past start as history (allowPast,source: "import") now sendsbooking.createdwithhistory: true(it sent no webhook). - Behavior change:
POST /v1/office/jobs/{id}/completewithsilent: truenow sendsjob.completedwithsilent: true(it sent no event).silentstill skips the review text and the payout.atmay go back to the job's own day for any job that started more than 30 days ago (was only imports and jobs booked after their start). - Unchanged and now documented:
POST /v1/office/jobs/{id}/cancelwithnotify: falsesendsbooking.cancelled(withtechs) as always;POST /v1/office/jobs/{id}/historysends no webhook, deliberately (updatedAtmoves, soupdatedSincereads pick it up). One table lists what each office path sends.
#2026-10-12 (fifty-first update: live positions and risk for platforms)
For your platform's own dispatch view (Live positions and risk). Fields, one endpoint and two webhooks are added; who sees a tech's position on a platform key now depends on the grant (marked Behavior change). One migration (tech_locations accuracy, heading and speed; live_signals).
- New:
GET /v1/office/dispatch/live: positions, field statuses (off,idle,en_route,on_site), current and next job, and per-job ETA, signed minutes late, risk level and reason for one day;branchandclientfilters, ETag/304, poll every 30 to 60 s. - New on
GET /v1/office/dispatch: lanes carryfieldStatus,currentJobId,nextJobIdandlocation.accuracy/heading/speed; stops carryrisk(levelon_time/at_risk/late,eta,minutesLate,reasontraffic/previous_overrun/not_started/tight_schedule/unknown,basislive/chained/actual),enRouteAt,arrivedAt,completedAt,plannedOrder,actualOrderandactualDriveMinutes. - New on job reads (
GET /v1/office/jobs/{id}):enRouteAt(witharrivedAtanddoneAt). - New webhooks:
dispatch.position(at most one per tech per 60 s, only after 100 m of movement or a status change, only on the clock; not sent to*subscriptions, subscribe by name) andjob.risk_changed(before/after, evaluated every minute). - New:
POST /v1/tech/locationtakes optionalaccuracy,headingandspeed. - Behavior change: positions for platform keys (who sees a position): a grant with
peopleNames: "full"sees a tech's position while the tech is on the clock (30 minutes either side of their working windows and visits) on the dispatch board, the live read andGET /v1/office/live(the live board showed it to platforms only on a job); any other grant only while the tech is working a job and the fix is at most 15 minutes old (the dispatch board showed it on the clock). Never off the clock. - Retention: CrewLink keeps one last known position per tech (no track) and now deletes it once it is 30 days old.
#2026-10-12 (fiftieth update: an integration can own technician availability)
Your platform is where acme-crew's techs set their availability; CrewLink now fully supports an integration owning it. New: Owning technician availability from your system. Fields and endpoints are added; nothing is removed. The office, API keys and techs of a managed company can no longer change those fields (marked Behavior change); no other company changes until it is set.
- New:
GET /v1/office/team/{id}: one person, the same object as a list item, with the same isolation (404for another company's people) and name rules (per-grantpeopleNames). - New: a company setting
availabilityManagedBy(nullor a platform slug), read and set withGET/PUT /v1/office/availability-management(owners; a platform key may claim or release it for itself) and shown inGET /v1/office/mecompany(withavailabilityManagedByName). Per person:availabilityManagedByonPATCH /v1/office/team/{id}(null,crewlinkor a slug). Team reads carryavailabilityManagedBy,availabilityManagedByNameandownAvailabilityManagedBy;GET /v1/tech/profilecarriesmanagedByandmanagedByName. - Behavior change: while set, everyone but that platform gets
409 managed_by_integration(managedBy,managedByName, a message naming it: "Hours for this team are managed in Acme. ...") onPUT /v1/office/team/{id}/availability,PATCH /v1/office/team/{id}withhours,availability,homeLat/homeLng,address,timezoneormaxTravelMiles,POST/DELETE /v1/office/blocks,POST/DELETE /v1/office/team/{id}/basesandPATCH /v1/tech/profile. An owner may override one request withforce: true(body) or?force=true(DELETEs); it is recorded (newaudit_events, listed asoverrides).forcefrom anyone else is403. Calendar busy time (Google, Outlook) keeps syncing and can still be removed; booking is unchanged. The web app shows those fields read-only with that note.acme-crewis set toacme(release step;cli.ts platform-availability). - New:
person.updated,availability.updated,block.created,block.deletedandperson.base.updatedcarrysource(platform,office,api,tech,admin,system),platform(the slug when a platform made the change) andforced, so an integration can skip its own echoes (webhooks.md).availability.updatedandperson.updatedalso carryavailability(every window per day, the shapePUT …/availabilitytakes) andperson.updatedcarriesmaxTravelMiles;block.deletedcarries the block'sexternalRefsandblockSource;block.createdlists itstimezoneandexternalRefs(they were sent but not documented). - New:
POST /v1/office/team/{id}/basesanswers409 duplicate_external_refwithbaseIdwhen one of itsexternalRefsids is already on a base of the company (a resent base used to be409 base_conflicton the same days, or a second base on other days). - Docs:
PUT /v1/office/team/{id}/availabilityanswersavailabilityin the request's shape (every window per day) next tohours(each day's envelope, kept for compatibility); both are on team reads.GET /v1/office/blocks: at most 92 days per call (422 invalid_request, "Up to 92 days, from on or before to."), no cursor; page by date windows and de-duplicate byid. cli.ts default-hoursskips people whose availability an integration owns.
#2026-10-14 (forty-ninth update: lanes, speed limits and CrewLink's own map style for directions)
Nothing here is breaking: fields are only added, and only when there is something in them.
GET /v1/field/jobs/{id}/route: each step may carrylanes(lane guidance for its maneuver:indications,valid, and from MapboxactiveandvalidIndication), and the route may carrymaxspeed(posted limits, one per segment ofgeometry,{ speed, unit }ornull). Both are left out when the provider doesn't know them, so a step without lanes looks exactly as before.mapStyleaddsstyle(crewlink-nav: CrewLink's own navigation style, carried by the apps in light, blue and dark),version(1) andthemes(["light", "blue", "dark"]).urlandtokenare unchanged; apps that still readurlkeep working.
#2026-10-12 (forty-eighth update: open times not tighter than intended)
From the follow-up QA of the conservative drive plans (calibration). Nothing is added or removed; two answers change (marked Behavior change).
- Behavior change: the booking page's availability (
POST /v1/public/{slug}/availability,POST /v1/office/availabilityand platform availability) hides a start only when its predicted extra driving is more than 20 minutes over the day's best; it compared the padded minutes a plan allows, so the time of day's padding counted as a detour (a tech starting at 5:00 showed 29 of the office's 45 times). It now shows the same starts asPOST /v1/office/first-availablewhen the route is the same all day. Real detours are still hidden. - Behavior change: with no road data for a region, two visits at the same address are no drive apart (were 3.7 minutes, planned at 7): a second 60-minute tune-up at one address can start 90 minutes after the first, not 105. The company's buffer between jobs still applies.
#2026-10-14 (forty-seventh update: product analytics identity and opt-out)
Nothing here is breaking: one endpoint and one setting are added.
- New:
GET /v1/me/analytics: what CrewLink's own apps send to product analytics for this sign-in (an id, the role, plan, trade, dates and demo/test flags; never a name, email or phone), orenabled: falseduring a super admin's act-as session or when the company opted out. - New:
PATCH /v1/office/settingstakesproductAnalytics(on,off), and its response includes it.offstops CrewLink's own product analytics about the company everywhere.
#2026-10-14 (forty-sixth update: "Count KPIs from")
Nothing is removed; a setting and fields are added. KPIs of a company that sets the date change (marked Behavior change); nobody's changes until it is set.
- New setting
metricsFrom("Count KPIs from", a local date ornull) onPATCH /v1/office/settings(owner only) and inGET /v1/office/mecompany. - Behavior change: with it set, every KPI and metric starts no earlier than that day:
GET /v1/office/kpis(and the KPI exports, PDF and weekly email),GET /v1/office/reports,/reports/status,/reports/heatmap,GET /v1/office/brain/summary, and Wins (goals, streaks, the company streak, leaderboards, tech stats; badges and feed entries from before the change hidden, not deleted). See Count KPIs from. Pay, invoices, billing and job lists are unchanged. - New: those responses (kpis, reports, reports/status, reports/heatmap, brain/summary) carry
metricsFrom.
#2026-10-14 (forty-fifth update: the platform contract, checked end to end)
From the integration QA of 2026-10-12 (a platform key and a company key against go.crewlink.ai). Nothing is removed; two answers change (marked Behavior change).
- Behavior change: a platform key sees techs as "First L." in words too. A refusal's
message(409 slot_taken,409 tech_busy: "Maria G. is already on job ..."), a queued booking'sreason(202),intakeReasonandattentionReasonon job reads and the dispatch board, and the same fields in webhook deliveries to a platform's endpoints (such asrequest.created'sreason) used to carry the tech's full name. The company's own keys and endpoints are unchanged. See platforms.md. - Behavior change:
GET /v1/office/dispatchanswers422 invalid_requestfor adatethat doesn't exist (2026-13-45,2026-02-30); it used to answer an empty board. - Fixed: a time held with
POST /v1/office/holdsand then booked readsource: "office"whoever held it; it now readsplatformfor a platform key,apifor the company key andofficefor a signed-in person (and the job records who held it, so people with ateamorownscope see their own).booking.createdalready saidplatform. Jobs booked before this release keep what they read. - Fixed:
request.createdfor a booking queued instead of refused (allowQueue, such as an import whose tech is busy) carriedexternalRefbut an emptyexternalRefs, and a filed request carried neither; everyrequest.creatednow carries all three, like other job events. The delivery log'sjobIdfilter also matchesdata.requestId, so it findsrequest.created. - New:
testonPOST /v1/office/team: add a test person in one call (wasPATCHonly; onPOSTit was ignored). - The OpenAPI spec now lists, on every route, the answers any call can give besides the route's own:
429and500everywhere,422where a body, query or path is validated,400where there's a body,401and403on keyed routes, and400 company_requiredand404on the office and CrewConnect routes a platform key reaches. Only the spec changed. - Docs: a waiting visit request is declined (
POST /v1/office/requests/{id}/decline), not cancelled (platforms.md); the platform errors table lists400 bad_request. - New for integrators and CrewLink QA:
tools/qa/contract-live.mjsruns the platform flow against a server and checks every answer against the spec;apps/api/test/contract.test.tsdoes the same in the API suite.
#2026-10-14 (forty-fourth update: who sees whom, and switching companies)
From the authorization QA of 2026-10-12 (SECURITY.md). Marked Breaking where an answer or a refusal changes.
- Breaking:
POST /v1/auth/switchneeds a session that signed in with an emailed code (verify) or switched from one; anything else (a user token, passkey, welcome link or invitation sign-in) is403 email_unproven.GET /v1/auth/companiesand thecompaniesof a sign-in list the other companies only for such a session. Before, any account whose email someone had typed in (a free sign-up, an owner adding a user) could switch into every other company with that email, including CrewLink's own. - Breaking (clients): a client company never sees a provider person's name.
GET /v1/connect/requestsandPOST /v1/connect/connections/{id}/requestsanswertechs: [](it listed first names);filedByof a request the provider's office filed for the client is{ "userId": null, "name": "<provider>" };invitedByof an invitation the provider made is the provider's name (also onGET /v1/public/invitations/{token}and in the invitation email). In messaging, the provider's people show as the provider's name. A client connection never shares people (visibilitystayscompany). - Behavior change (techs): the field app and messaging show other techs as "First L." and never a client's or a CrewConnect partner's company name (it reads "a partner").
- Breaking (platforms): platform keys see techs and office staff as "First L." everywhere, including the schedule, the live board, requests, intake, emergencies, error messages and webhooks; the live board shows a position only while the tech is working a job and the fix is fresh. Platform keys no longer reach people's sign-ins, documents, credentials and pay, pay periods, invoices (writes), recurring work (writes), reports, KPIs, exports,
jobs.csv, plan vs actual, the fleet, linked calendars or/v1/ops(403 forbidden; list). Webhooks to a platform never carrymessage.*, and a grant withoutwebhooksstops the platform's company-level endpoints too. - New: grants carry
peopleNames(shortdefault,full): a platform that runs the company itself sees full names there (platforms.md). Set by the owner onPOST/PATCH /v1/office/platforms/{slug}/grant, shown onGET /v1/platform/grants; your platform's grants onacme-crewandacme-testarefull. - Fixed: an emergency booked by a platform whose grant has homeowner messaging off texted the homeowner.
- Fixed: parallel sign-in code guesses counted as one try, and one code could sign in twice when two requests raced.
- Fixed: an owner at CrewLink's own company who isn't a super admin could issue a super admin's token, turn them off or change their role (
404now).
#2026-10-14 (forty-third update: invoices without a payment account, a health check)
Nothing is removed; one endpoint and one field are added, and one answer that failed now succeeds (marked Behavior change). From the QA of 2026-10-12 (customer-facing pages and production readiness).
- Behavior change:
POST /v1/office/invoices/{id}/sendno longer answers502 payments_unavailablefor a company with no connected Stripe account (every company until Connect onboarding exists): the invoice is sent, the customer's link shows the balance withpayment: nulland asks them to pay the company directly. New fieldpayOnlinesays whether the link offers a card payment.502now only means the provider failed for a company that does take payments online. - A booking for a service with a deposit, at a company with no connected Stripe account, answers
deposit: nullwithout asking Stripe (as it already did after Stripe refused); the manage link shows the deposit as due, taken by the office. - New:
GET /v1/health, public:ok,db, the runningreleaseanduptimeSeconds;503when the database doesn't answer. For uptime monitors. GET /v1/public/{slug}listsservicesin the order the company added them (it could change between loads).- The docs' base URL is
https://go.crewlink.ai(api.crewlink.aidoesn't resolve yet).
#2026-10-14 (forty-second update: scheduling QA before go-live)
Nothing is removed; more times are offered for long drives where CrewLink has no road data (marked Behavior change). From the scheduling QA of 2026-10-12 (calibration).
- Behavior change: where CrewLink has no road data for a region (today every state but Illinois), a long drive is planned at highway speed past its first 30 km of road instead of 45 km/h all the way, and rush hours apply to an hour of free flow's worth of a long drive only (as the planning curve already did). Open times for addresses far from the tech come earlier: a Phoenix tech to Tucson (179 km) was first offered at 2:15 PM after a 7:00 AM start, now 11:45 AM. Drives under about 21 km straight line are planned exactly as before; every measured leg is still planned above OSRM's free flow, with the curve, the 20% margin and the buffer on top.
- Fix:
GET /v1/office/dispatchno longer lists a planned leg astightwhen booking accepted it at the edge. The board priced the drive at the predicted (later) departure, where the planning curve is steeper before 7:45 and toward 5:15 PM, so about a quarter of legs booked at the earliest minute after the previous job showed as 1 minute short. It now prices it at the departure booking checks, sotightmeans exactly "booking would refuse this leg now". - Fix:
outsideArea(booking page,POST /v1/office/availability,POST /v1/office/first-available, intake) counts a tech's base or trip area on its days: an address only a base week covers answeredoutsideArea: truenext to that week's open times. A CrewConnect send for a base day is no longer refusedout_of_areathere.
#2026-10-14 (forty-first update: the field app only claims texts that go out; what techs see of other people)
Nothing is removed; fields are added, and two names change for techs (marked Behavior change). From the tech QA of 2026-10-12.
- New: each stop in
GET /v1/tech/dayhastextsCustomer: whether CrewLink texts that job's customer.falsewhen homeowner messages are off for the job (notify: false), for the company (homeownerMessaging: "off") or for the platform that booked it, or when the customer has no phone. Field apps should say "texts the customer" only when it'strue. - New:
POST /v1/tech/jobs/{id}/stageanswerstexted: whether the on-my-way text (en_route) or the review link (done) was queued. - New: each change in a check-in's answer has
customerTexted: whether that job's customer is texted about the move. - Behavior change: in Messaging, a tech sees the other techs of their company as "First L." (first name, last initial) everywhere: members, senders, mentions, inbox previews, direct-message titles and
GET /v1/messaging/recipients. The office still sees full names, and a tech still sees their own full name. - Behavior change: when a client company cancels a job, its job thread reads "Cancelled by the client." for techs; the office still reads the client's name.
#2026-10-14 (fortieth update: field managers and narrowed dispatchers can book and work their own queue)
Nothing here is breaking: people with a team or own scope may call a few more routes, each narrowed to their own jobs. From the office-roles QA of 2026-10-12.
POST /v1/office/first-availablenow answers people with ateamorownscope (every field manager by default) instead of403 scoped, so the web app's New job shows them open times.GET /v1/office/intake,GET /v1/office/jobs,GET /v1/office/jobs.csvandGET /v1/office/jobs/{id}answer them too, with only the jobs they (or their team) filed, as the docs already said.GET /v1/office/intake/{id}/options,POST /v1/office/intake/{id}/discardandPOST/DELETE /v1/office/jobs/{id}/attentionwork on their own jobs; another person's job is404. See Roles and scopes.- Fix: a field manager or dispatcher with a
teamorownscope could open, read and post in the thread of any job of the company (GET /v1/messaging/jobs/{jobId}), including jobs they can't see on the schedule. They're now in the threads of the jobs they see only: another job's thread is403 not_memberand stays out of their inbox, unread counts, updates and search.
#2026-10-14 (thirty-ninth update: files are checked in storage on confirm, and deleted with their row)
Nothing is removed; confirming an upload can now be refused (marked Behavior change). This prepares real file storage (S3) on every server; until a server is switched to it, the checks below have nothing to look at and confirm answers as before.
- Behavior change: confirming a presigned upload checks the file in storage first.
POST /v1/office/jobs/{id}/attachments/{attachmentId}/uploaded,POST /v1/office/team/{id}/documents/{docId}/uploadedandPOST /v1/tech/jobs/{id}/photos/{photoId}/uploadedanswer409 not_uploadedwhen nothing is at the upload URL yet (finish the PUT, then confirm again), and422 upload_mismatchwhen the file is bigger than the size you declared or than the limit (20 MB job files, 25 MB documents and photos), or isn't the declared Content-Type. A mismatched file and its pending row are removed: start the upload again.502 storage_unavailablewhen storage didn't answer. A confirmed attachment'sbytes(and a document'ssize) is now the file's real size. - Behavior change: posting a message with attachments checks each file the same way: nothing uploaded, too big or another type is
422 bad_attachment(a mismatched file is deleted). - Upload URLs now accept only the declared
Content-Type(it's part of the signature). Every client that followed the docs ("PUT with the same Content-Type") is unaffected. - Removing a job file (
DELETE /v1/office/jobs/{id}/attachments/{attachmentId}), a person's document (DELETE /v1/office/team/{id}/documents/{docId}) or a message deletes its file from storage too. Before, the bytes stayed (unreachable). - Download links (
url) for job files, documents and message attachments carry the file's name, so a browser saves it under that name. Storage keys hold only ids and random characters, never a file name.
#2026-10-14 (thirty-eighth update: turn-by-turn directions for the field app)
Nothing here is breaking: two endpoints are added.
- New:
GET /v1/field/jobs/{id}/route: a driving route to one of the tech's jobs, fromfrom=<lat>,<lng>(or, without it, their last reported position, their previous stop today, or where their day starts), with the whole line as GeoJSON, turn-by-turnsteps(instruction, road, distance, time, maneuver, a line to speak), the drive's distance and time with live traffic where available (provider),eta,arriveBy(the job's start) andmapStyle(a style URL and a public map token, ornull). A job the tech isn't on is404; up to 30 routes a minute per tech (429 rate_limited);502 route_unavailableand503 not_configuredwhen no route can be given. - New:
GET /v1/field/next-stop: the tech's first stop today, in plan order, that isn't done ({ "jobId": null }when there is none).
#2026-10-14 (thirty-seventh update: exact addresses, the plan meter's pace)
Nothing is removed; fields are added, and approximate addresses are refused where the caller says they are approximate (marked Behavior change). From the QA of 2026-10-11 (P1-1, minor).
- New:
GET /v1/public/geocoderesults carryprecision:rooftop,address,street(only the street: that house number wasn't found) orlocality(an area). A label that lost the house number or the street direction you typed (9446 S Raymond Aveanswered asRaymond Ave, Oak Lawn;6530 W 79th Stas6530 79th St) gets them back. - Behavior change:
POST /v1/office/jobsandPOST /v1/public/{slug}/bookingstakegeoPrecision(thatprecision) andallowApproximate. Astreetorlocalityplace withoutallowApproximate: trueis422 approximate_address. LeavinggeoPrecisionout is not checked, so integrations that send their own coordinates keep working; imports (source: "import") and platform keys default toallowApproximate: true. - New: jobs read
geoPrecision(list, detail, intake): how exact the place is, ornullwhen nobody said. - Plan usage (
GET /v1/office/billing, the meter and the plan-usage CSV) no longer counts imports (source: "import"), test work (for a test client, or with a test person on it) or, in a demo account, the jobs the demo generator wrote; cancelled ones were never counted. The meter'sonPaceFornow projects from at least 7 days of the period (all of it when shorter), so a busy first day no longer reads as thousands of jobs.
#2026-10-14 (thirty-sixth update: drive times planned on the slow side, and a buffer between jobs)
Nothing is removed; fewer times are offered (marked Behavior change).
- Behavior change: every open time, booking, hold and move now plans each drive conservatively for that hour (road free flow times the larger of the learned traffic and a city curve of about 1.4x on weekday daytimes, up to 2x at rush hour, plus a 20% margin, rounded up) and keeps a buffer of 10 minutes (was 5) before each visit, after the drive. Gaps the engine used to accept at or below Google's best case (QA 2026-10-11: a 20-minute gap from Evanston to Lincoln Square at 1:55 PM) are now
409 slot_takenwithreason: doesnt_fit, and the booking page offers the next time that fits. Same rule on every path: availability, the booking page and holds,POST /v1/office/jobs, moves and drag-and-drop, recurring visits, intake, CrewConnect, emergencies and trips. Imports (source: import) are unchanged. See Drive time and the buffer. - New setting
bufferMinutes("Buffer between jobs", 0 to 60, default 10) onPATCH /v1/office/settings(owner), in its response and inGET /v1/office/mecompany. Settings in the web app has it under Scheduling. GET /v1/office/dispatchrisksnow also lists planned tight legs from today on: two booked jobs of one tech with less time between them than the planned drive plus the buffer (kind: tight,minutesLate= minutes short). Jobs booked before this release are never re-timed; this is where the ones that are now too tight show up. Live ETAs from a tech's previous job use the plan's predicted drive (was a straight line at 40 km/h).
#2026-10-14 (thirty-fifth update: service areas on every automatic path, where the day starts, branch locations, two-tech moves)
Nothing is removed; fields are added and two automatic paths now refuse what they used to book (marked Behavior change). From the QA of 2026-10-11 (P0-2, P2-1, P2-2).
- Behavior change: the recurring scheduler keeps New job's service-area rule. A visit whose address no tech's area covers is no longer booked: it is filed in the intake queue (
status: queued,source: recurring,intakeReasonand needs attention "Out of service area",job.needs_attentionandrequest.createdgo out), the customer gets no text, and the due date moves on.POST /v1/office/recurring/runaddsoutOfArea:[{ recurrenceId, jobId, due }]. While such a visit waits, the series queues no other. Intake's place-it options suggest anyone qualified for it (as for a waiting out-of-area request). - Behavior change:
POST /v1/connect/connections/{id}/jobsrefuses an address outside every service area of the partner, or outside the area of thetechIdyou picked, with409 slot_takenand the new reasonout_of_area(withallowQueue, it is queued with that message instead). Passing a job on (POST /v1/connect/received/{jobId}/pass) only sends people whose area covers the address. - New: branches have a place. The branch object adds
address(one line, ornull) andlocated(falsefor a branch with no usable location, which covers nothing).POST /v1/office/branchesandPATCH /v1/office/branches/{id}takeaddress(withlat/lng;PATCHaddress: nullclears the line). When an owner moves a branch, its people who start from it (added without a home: home is a copy of the branch's spot) move with it. - New: the person object adds
startsFrom:{ kind: home | branch | none, lat, lng, address, branchId, branchName, text }, where their working day starts. The first job is reached from there through the same check as any leg: shift start + drive at that time of day + buffer before job 1 (engine.md). - Behavior change: someone with no start origin (no usable home, 0,0 or none, and no located branch) is never offered or auto-booked; naming them in
techIdsanswers409 slot_taken(not_on_team) "… has no start location: add a home address on Team, or a location for their branch in Settings". Nobody created through the API could get there before (a home or abranchIdwas always required), so this only guards bad data. - Docs:
POST /v1/office/jobs/{id}/movespells out multi-tech moves: withouttechIdsa two-tech job keeps both (each checked; one who can't make it is replaced by the best fit), withtechIdsseveral people go, all checked, and the409names the first who can't.
#2026-10-14 (thirty-fourth update: choose the grid of open start times)
Nothing here is breaking: a field is only added, and leaving it out answers as before.
- New:
stepMinutesonPOST /v1/office/availabilityandPOST /v1/office/first-available:1,5,15(default),30or60. Start times lie on that grid of local minutes where the job is (60: whole hours only;1: any minute, such as08:07), each still one a tech can make with the drive and their other jobs. Any other value is422 invalid_request. The booking page's own availability stays on 15 minutes. - A start off the 15-minute grid (such as
08:07) now holds and books as given everywhere an exact time is sent (POST /v1/office/holds,POST /v1/office/jobswith or withouttechIds, moves): before, withouttechIdssuch a time could be refused as409 slot_takeneven when a tech was free.
#2026-10-14 (thirty-third update: history corrections keep test people on cancelled imports)
POST /v1/office/jobs/{id}/historyon a cancelled import now accepts test-flagged people, because a cancelled job is never booked or counted. A done import still follows the test split.
#2026-10-14 (thirty-second update: book a cancelled job's external id again; queued webhooks stop when an endpoint is off)
Nothing is removed; two answers change (marked Behavior change).
- Behavior change: an external id (
externalRefs/externalRef+externalSource) is now unique per company among jobs that aren't cancelled. When every job holding an id is cancelled (or a declined request), a new job may take it:POST /v1/office/jobs,POST /v1/office/requests, the booking page and its requests answer201with a new id where they used to answer409 duplicate_external_ref. While a job that isn't cancelled holds it, it's still409 duplicate_external_refwith thatjobId. The cancelled job keeps its ids (reads and webhooks still show them). There is still no reopening a cancelled job. See platforms.md. - Behavior change:
GET /v1/office/jobs(and.csv) withexternalRefand nosortnow orders jobs that aren't cancelled first, then the newest filed, sojobs[0]is the job the id points to now (all cancelled: the most recent). An id booked again lists every job that had it.sortis no longer defaulted in the OpenAPI schema; leaving it out is stillupdated_ascwithoutexternalRef. - Webhooks: deliveries still waiting when their endpoint is turned off, or (platform-wide subscriptions) when the company no longer grants the platform
webhooks, are no longer sent: they're markedfailedwithlastErrorendpoint inactiveorgrant revoked(attemptsunchanged) in the delivery log.platform.grant.*events still go out.
#2026-10-14 (thirty-first update: webhook delivery log, and more fields on booking events)
Nothing here is breaking: fields are only added.
- New: delivery log for webhook endpoints.
GET /v1/office/webhooks/{id}/deliveries(owner; a platform key: endpoints it subscribed) andGET /v1/platform/webhooks/{id}/deliveries(platform key: any endpoint it made; only companies that grant it now). Each row:id,event,companyId,status(pending,delivered,failed),createdAt,attempts,lastAttemptAt,nextAttemptAt,lastStatusCode,lastError(up to 500 characters),deliveredAtand thepayloadas sent. Filtersevent,jobId(data.jobIdordata.bookingId),status,limit(1 to 200, default 50),before(cursor;nexton the answer). Newest first. - New:
POST .../webhooks/{id}/deliveries/{deliveryId}/retryon both routes queues one delivery again now (same id and body), whatever its status. - Every delivery now records the HTTP status and time of its last attempt (they show in the log; deliveries made before this release have
nullthere). - Every
booking.created,booking.rescheduledandbooking.cancellednow carries, besides what it had:jobId(the same number asbookingId),status(the job's status after the change, as on job reads; alwayscancelledonbooking.cancelled),endandlocalEnd(the end of the first day's visit, ornullwithstart),clientCompanyIdandclient({ id, name }of the job's client company, ornull; on every source now, not onlyclientandapi).bookingIdandtechs(integer tech ids) are unchanged. See webhooks.md and the platform example. - Docs:
booking.createdlists theplatformandimportsources.
#2026-10-14 (thirtieth update: imports keep the old app's double-bookings, and can be placed in the past)
Nothing is removed; one answer changes for imported jobs (marked Behavior change).
Behavior change:
POST /v1/office/jobs/{id}/moveon an imported job (source: "import") to a time that has passed is no longer409 in_the_past:allowPastis implied for imports (falseturns it off;422on other jobs, whose past times stay409 in_the_past). The job is booked as history withtechIds(else the techs already on it;422with none): no availability, hours or overlap checks, real visits of the service's length, so it readsoverdueuntil marked done. No homeowner text or reminder (queued ones are dropped);booking.created/booking.rescheduledunlessnotify: false. The answer addshistory: true.POST /v1/office/jobs/{id}/historystays for cancelled and done imports.New:
allowOverlap: trueonPOST /v1/office/jobs/{id}/movefor imported jobs (source: "import") withtechIds(422on any other job, withouttechIds, or withallowOutsideHours: false). An overlap with another visit of the tech is booked anyway instead of409 tech_busy; time off, skills and the team are still checked. The answer addsdoubleBooked([{ personId, conflictJobId }]) when an overlap was skipped, and the moved job is flagged needs attention with the reasonDouble-booked with #<conflictJobId> (imported from the old app). Withnotify: false(or the job's texts off) the flag sends nothing (nojob.needs_attention); otherwise it goes out as for any flag. Platform keys withwritemay use it.
#2026-10-14 (twenty-ninth update: correcting an imported job's history)
Nothing here is breaking.
- New:
POST /v1/office/jobs/{id}/history{ start | local, end?, techIds }for office keys, users and platform keys (write). For a job imported withsource: "import"that is cancelled or done: sets its visit time and techs exactly as sent (no availability, hours, drive or overlap checks;enddefaults tostartplus the service's booked length), keeping its id,externalRefsand status (doneAttoo). Nothing is sent (no homeowner text or email, no tech notification, no webhook); the timeline getshistory_corrected. A cancelled job stays off the schedule like any cancelled job (no visits: it never blocks a booking, shows on the board or dispatch, or counts in KPIs or pay); a done job's visits are replaced.422 not_historyfor any other job. - Job reads of a cancelled import whose history was corrected show its old
techsandend(any other cancelled job:techs: [],end: null, as before). - There is still no
DELETEfor jobs.
#2026-10-14 (twenty-eighth update: visit requests filed for a client, people bookable when added)
Nothing here is breaking; one default changes for platform keys (marked Behavior change).
Behavior change:
POST /v1/office/teamtakesskills: "all": every active service of the company, now and later (skillsis kept equal to the services' bits when one is added, changed or turned off;skillsAllon team reads). It is the default for platform keys whenskillsis left out (it was1), so a tech your platform adds is bookable for every service without a manual step; office keys and users still default to1.PATCH /v1/office/team/{id}takes"all"too; a number turns it off.New:
homeLat/homeLngonPOST /v1/office/teammay be left out whenbranchIdis sent: home is the branch's location (422with neither).New:
POST /v1/office/requestsfor office keys, users and platform keys (write): the booking page's "request a visit" body (serviceId,lat,lng,customer,address,notes,requested) plusclientCompanyId(one of the provider's client companies, active;404otherwise, as onPOST /v1/office/jobs),externalRefs/externalRef+externalSource(409 duplicate_external_refwithjobId) andnotify. The request waits for the office (waiting); the client sees it onGET /v1/connect/requestsaspending, andrequest.createdcarriesclient,filedByandsource(platform,officeorapi).POST /v1/public/{slug}/requestsis unchanged.
#2026-10-14 (twenty-seventh update: imports placed at their old commitment, test clients and test people)
Nothing here is breaking. Two answers can change for companies that use the new flags (marked Behavior change).
- New:
POST /v1/office/jobs/{id}/moveon an imported job (source: "import") withtechIdsplaces it at its old commitment the way an import is booked:allowOutsideHours(implied;falseturns it off;422on other jobs). From the intake queue (queued, unscheduled) or the board; outside the techs' hours or days, the on-call window or the engine's drive; on the techs' clock. The answer addsoutsideHours: true; a real overlap with another visit is the409 tech_busy(personId,conflictJobId) thatPOST /v1/office/jobsimports already give. Platform keys withwritemay do it. An ordinary move still clearsoutsideHours. - New: test clients and test people (office.md).
teston client companies (PATCH /v1/office/clients/{id}{ "test": true }, owner, kindclientonly) and on people (PATCH /v1/office/team/{id}{ "test": true }, platform keys too);teston client rows, team reads, schedule techs and visits, and dispatch lanes, stops and queue rows. A test client's jobs only go to test people; other jobs never do, everywhere a tech is chosen. New optionalclientCompanyIdonPOST /v1/office/availability,POST /v1/office/first-availableandPOST /v1/office/holds(kept on the hold) so a client's job is offered the right people; booking a hold may sendclientCompanyIdtoo. - Behavior change: once someone is marked test, real jobs are never offered or booked to them, and a named test tech is
409 slot_takennot_on_team. Nothing changes until a flag is set. - Behavior change: test people (and the jobs they work, and test clients' jobs) are left out of
GET /v1/office/kpis, the Wins team numbers and leaderboard,GET /v1/office/payand its CSV, and the summary report. NewincludeTest=trueon the KPI and pay reads brings them back.
#2026-10-14 (twenty-sixth update: the dispatch board in one read)
Nothing here is breaking.
- New:
GET /v1/office/dispatch?date=YYYY-MM-DD(optionalbranch,client): everything the Dispatch page draws for one local day in one call. A lane per person working that day (techwithdisplayName,timezone,branchId,home, that day'shourswindows; livestatusok,tight,late,idleoron_job,doingandlocation;stops;blocks;utilization), each stop with its ids (externalRefs), client, service, customer, address in parts, times,state,stage, flags anddriveMinutesBefore(the planned drive from the previous stop or home: the plan snapshot the techs are working to, else the engine's prediction;driveSourcesays which) and a liveetawhile the tech is on the way. Plus the intakequeue(oldest first, withageDaysandintakeReason),risks(late,tight,overdue,aging, each with amessage),countsand mappins. - Platform keys need
read. They see techs as "First L.", customers by name only (no phone) and a tech's position only while the tech is on the clock that day. People with ateamorownscope may call it: other people's jobs are busy blocks, with no live status. - Answers carry a weak
ETag; send it back asIf-None-Matchand an unchanged board is304with no body. Poll every 30 to 60 seconds; every call counts against the rate limit (1,200 a minute per platform key). See platforms.md for the full shape and an example.
#2026-10-14 (twenty-fifth update: old commitments on import, jobs on their address's clock, pending client requests)
Follow-ups from a platform's import. Nothing is removed; one answer changes (marked Behavior change).
- New:
allowOutsideHoursonPOST /v1/office/jobs, imports only (422withoutsource: "import"; the default for imports withtechIds,falseturns it off). A future visit the old system already confirmed is booked for those techs even outside their working hours, on a day they don't work, in the on-call window or past the engine's drive; their hours are not changed. The answer addsoutsideHours: true. A tech already on another visit then is refused with the new409 tech_busy(personId,conflictJobId; queued instead withallowQueue: true); time off, missing skills and people not on the team are still409 slot_taken. Withoutsource: "import", a time outside hours is409 slot_takenoutside_hoursas before. - New:
outsideHourson every job read (the job row: list, detail, intake, platform keys) and onGET /v1/office/schedulevisits:truefor a visit booked outside its tech's hours. A move (office, emergency or running-late) clears it. - Behavior change:
GET /v1/connect/requests(and/{jobId}, and thestatusfilter) reads a request with no visit aspending: one in the provider's intake queue used to readscheduledwithstart: null.schedulednow always means a visit is on the provider's board. A queued request can be taken back like any pending one.
#2026-10-14 (twenty-fourth update: each tech on their own clock, national service radius, default week for active techs)
For national companies whose techs work from home (for example one Chicago branch, techs across the US). Nothing is removed, but answers for such companies change (marked Behavior change).
- Behavior change: a tech without a branch works on their home's clock. A person's zone is now their own
timezone(new, below), else their branch's, else (no branch) the zone of their home from coordinates (a bundled offline map), else the company's. Before, everyone without a branch was on the company's zone, so a Baltimore tech's "8:00 to 5:00" ran 8:00 to 5:00 Chicago time (9:00 to 6:00 in Baltimore). Their hours, days, time off and bases are on their own clock, andtimezoneon team reads,person.updated,availability.updatedandPUT /v1/office/team/{id}/availabilitysays which. Zones with the same clock are one (America/Indiana/IndianapolisreadsAmerica/New_York). Companies whose people all have branches are unchanged. - Behavior change: an address answers on its own clock.
POST /v1/office/availability,POST /v1/office/holds,POST /v1/office/jobs,POST /v1/office/first-availableand the public availability givetimezoneandlocalin the zone of the address: a branch whose area holds it gives its own (as before); otherwise, for a company with people without a branch, the zone of the address itself (if only techs on another clock cover it, the nearest one's). WithtechIdsonPOST /v1/office/jobs, the techs' zone.startwas always the true instant; store it. - New:
timezoneonPOST /v1/office/teamandPATCH /v1/office/team/{id}(platform keys too; IANA name,422for an unknown one;nullonPATCHgoes back to branch or home). Team reads addownTimezone(what was set, ornull) andserviceRadiusMiles. When a person's zone changes (theirtimezone, a home in another zone, another branch), their booked jobs and time off are re-expressed on the new clock keeping every instant; thePATCHanswer addstimezoneandrezoned: { jobs, blocks }. - New:
defaultTechRadiusMilesonPATCH /v1/office/settings(owner) and inGET /v1/office/mecompany: how far from home people without a branch are offered work. A person'smaxTravelMileswins; unset, 40 km as before. A branch's people keep the branch'sareaKm.outsideAreais true when no one's area covers the address. On a base day (POST /v1/office/team/{id}/bases) the radius is around the base. - New:
hoursSourceon the person (default_recent_activityornull). There is still no default schedule, with one exception run by CrewLink ops (cli.ts default-hours <slug> --active-since 90d): techs with no hours who completed at least one job in the last 3 months get Monday to Friday 8:00 to 17:00 on their own clock, markeddefault_recent_activity(withperson.updatedandavailability.updated); techs with no recent completed work keep no hours and are never offered. Any hours set later replace it. On go.crewlink.ai it runs daily foracme-crew. - What to do (your platform): read
timezonefromGET /v1/office/teamorperson.updatedbefore sendingPUT /v1/office/team/{id}/availabilitywith atimezone(a different clock is422 timezone_mismatch), and showlocalwith the answer'stimezone.
#2026-10-14 (twenty-third update: demo click-through fixes, people's emails, invite the whole team, booking-page deposits, which company at sign-in)
Nothing here is breaking.
- New:
POST /v1/office/team/invite-all(owners; not platform keys): atechuser for every active person with an email and no sign-in yet, each emailed an invite; answers counts (created,invited,alreadyHadLogin,noEmail,emailInUse,failed) and the users added. - People have an
email: set it onPOST /v1/office/teamandPATCH /v1/office/team/{id}(nullremoves it; stored lower case), returned on every person (platform keys included; never on customer pages).phoneis on POST as well as PATCH.GET /v1/office/team?email=now matches the person's own email as well as their sign-in user's. - Sign-in: every session answer (code, passkey, invitation link) adds
companies: each company the email can sign in at (id,name,slug,role,lastUsedAt), most recently used first. NewGET /v1/auth/companiesandPOST /v1/auth/switch{ companyId }: a new session as the same person's user at another of their companies (only where that email has an active user; the old session ends). The web app asks "Which company?" after sign-in and has a company switcher at the top of the sidebar. - New:
GET /v1/office/brain/days?limit=: the most recent reviewed days, newest first (Plan vs actual opens on the first). - Messaging: one partner channel per pair of companies. Two companies connected both ways used to get a channel per connection; existing pairs are merged (messages move to the older channel, the other is removed), and a channel stays open while either connection is active. See messaging.md.
- The web app's pages have their own addresses (
/dispatch,/intake,/jobs,/reports/plan-vs-actual,/dispatch?date=...&job=<id>, ...): those paths now answer200with the office app's link-preview tags instead of404. - Sandbox (
go.crewlink.ai) demo accounts: bookings carry a realistic booking time (createdAt: their lead time before the visit, or spread over the past weeks for work far ahead) so Intake, Reports and the plan meter read like a real account; days ahead keep room (about 92% of a day's work is on the books the day before, tapering to 42% two weeks out); tech arrivals and the traffic provider's minutes are realistic; techs earn job pay; people talk in Messages; the Wins feed shows the last days. - Deposits are paid on the booking page now ("book then pay"): the confirmation shows "Pay the $49 deposit", and the manage link shows it until it's paid. See deposits. POST /v1/public/{slug}/bookings:depositaddspublishableKey(the Stripe publishable key for Stripe.js;nullwhen the server has none, and the office takes the deposit) andtestPay(development and test servers only). Newtexted: a confirmation text really went out; only then say "we texted you".GET /v1/bookings/{id}(and the answer ofPOST /v1/bookings/{id}/reschedule) addsdeposit(statusdue,paidorrefunded, with what the browser needs to pay it while it's due) andtexts(customer texts really go out on this server).- New:
GET /v1/bookings/{id}/deposit(manage token): the deposit, checked with Stripe first, so a payment the browser just confirmed shows paid without waiting for the webhook. - New on development and test servers only (
ENABLE_FAKE_PAYMENTS=1with no Stripe key):POST /v1/bookings/{id}/deposit/test-pay(manage token) marks the test deposit paid. Never on a production server. GET /v1/public/{slug}addstexts.- The confirmation text for a service with a deposit says the deposit is due and links the manage page to pay it ("A $49.00 deposit is due to hold the time. Pay it, change or cancel: <link>").
- Server setting:
STRIPE_PUBLISHABLE_KEY(withSTRIPE_SECRET_KEY) turns on card deposits on the booking and manage pages.
#2026-10-13 (twenty-second update: homeowner messaging off, no default schedule, next-business-day booking)
For the jobs your platform books on CrewLink. One change can refuse a request that used to work (marked Breaking).
- New: homeowner messaging off, three ways; any one off and a job sends its homeowner nothing (no confirmation, reminder, on-the-way or ETA, running-late move, reschedule, cancellation, review link or invoice), and the text worker drops anything already queued for it. See office.md and platforms.md.
- Per job:
notify: falseonPOST /v1/office/jobs,POST /v1/office/holds(kept when the hold is booked) andPOST /v1/office/jobs/{id}/move(stored on the job; a move withnotify: trueturns it back on).cancel(notify) andcomplete(silent) already had theirs. - Per company:
homeownerMessaging: "on" | "off"onPATCH /v1/office/settings(owner; defaulton), shown inGET /v1/office/me. - Every job read (
GET /v1/office/jobs, the detail, intake) and everyGET /v1/office/schedulevisit addshomeownerTexts(false: silenced). The hold answer addsnotify.
- Per job:
- No default schedule.
POST /v1/office/teamno longer needshoursoravailability: someone added without either has no working hours (every day off) and is never offered until hours are set. Before, it answered422. earliestDay: "next_business_day"onPOST /v1/office/availability(nothing before the next Monday-to-Friday day after today in the address's zone) and onPOST /v1/office/holds, which also takesleadMinutes(default 0) and answers the new409 too_soonfor a start before either.- Breaking: a client company taking back a scheduled request (
POST /v1/connect/requests/{id}/cancel) inside the provider'scancelCutoffHours(default 24) now gets409 too_late, the same cutoff as the homeowner's manage link. Pending requests can still be withdrawn any time, and the provider's office (and platforms on the provider's grant) still have no cutoff. What to do: cancel earlier, or ask the provider to cancel.
#2026-10-13 (twenty-first update: CrewLink staff, the CrewLink Demo account, demo calendars ahead)
Nothing here is breaking.
- New: the admin area for CrewLink's own staff (admin.md). Super admins are people of CrewLink's internal company (slug
crewlink,kind: "platform": never billed, no booking page, never a CrewConnect partner).GET /v1/admin/companies(search every company: plan, counts, created, last activity),POST /v1/admin/companies/{id}/act-as(a 1-hour owner session for one company, audited per request; never payouts, payments, plan or billing changes, closing or paying pay weeks, keys, sign-in tokens, platform grants or passkeys:403 acting_as),GET /v1/admin/platforms(platforms and their grants),GET /v1/admin/usersandPOST /v1/admin/users/{id}/superadmin(promote or demote; never the last one:409 last_super_admin), andGET /v1/admin/actions(the audit log). GET /v1/office/meaddssuperAdmin(CrewLink staff signed in as themselves; alwaysfalsefor a company's own people) andactingAs({ adminUserId, adminName, expiresAt }while CrewLink staff act as your owner, elsenull).company.kindcan beplatform(CrewLink's own company).GET /v1/ops/travelandGET /v1/ops/brainare also open to super admins (platform-wide numbers).POST /v1/auth/logoutwith an act-as token ends it and writesact_as_endto the audit log.POST /v1/connect/connectionsanswers404forpartner: "crewlink", andGET /v1/public/crewlinkis404: CrewLink's own company is never a provider.- Sandbox (
go.crewlink.ai): a new demo account, CrewLink Demo (crewlink-demo): HVAC, solar and roofing in one company, 8 techs, two Chicago branches, 12 weeks of history, connected for CrewConnect with the other demo accounts. Every demo account is now kept booked through the end of next month (at least 4 weeks ahead), refreshed once a release day.
#2026-10-12 (twentieth update: moving the old CrewLink's data)
Nothing here is breaking. For your platform's import of the old app (docs/dev/how-to.md, "Importing from the old CrewLink").
POST /v1/office/jobstakestechIds(who goes; a future time books them only if they can make it),allowPast(a start that has passed is written as history: booked as given with no availability check, no text and no webhook; the answer adds"history": true),source: "import"(impliesallowPast; no confirmation, queued or reminder text;booking.createdsayssource: "import") andfiledAt(imports only: the old filed date, kept ascreatedAt). WithallowQueue, a past start is queued rather than written as history. See office.md.- Fixed: a job filed in the intake queue by
POST /v1/office/jobswithallowQueuenow keeps itsexternalRefs(andexternalRef,addressParts); before, the ids were dropped, so a retry booked a second job. POST /v1/office/jobs/{id}/completetakespay(defaulttrue;false: no technician pay line). For history (a job imported, or booked after its start),atmay go back to the job's day instead of 30 days. Plan vs actual records the visit of the day ofat, or the person's last visit before it.POST /v1/office/jobs/{id}/canceltakesnotify(defaulttrue;false: no cancellation text).POST /v1/office/jobs:customer.phonemay be left out forsource: "import"and with the newcontact: { kind: "commercial" | "office" }; no text is sent for such a job. Newremind(defaulttrue,falsefor imports): no reminder text, stored on the job (the text worker drops queued reminders of a job with it off); an office move turns it back on. So an import texts the homeowner nothing: not when queued, booked, completed or cancelled.- Platforms: a client company (
kind: "client") that granted a platformwritelets it file requests as that client (POST /v1/connect/connections/{id}/requests) and take one back (POST /v1/connect/requests/{jobId}/cancel). Other CrewConnect writes stay off limits. See platforms.md.
#2026-10-11 (nineteenth update: scheduling rules checked against the old CrewLink)
From the logic review in docs/dev/logic-parity.md:
- New:
POST /v1/office/first-available: every start time someone qualified can make at an address, with who would go, earliest first (firstAvailable). No efficiency filter and no one-hour lead, for the office's schedule screen (POST /v1/office/availabilitystays the booking page's answer with a key). See office.md. GET /v1/office/schedule: each visit hasstate:held,scheduled,in_progress,overdue(the job's last visit ended and it isn't marked done) orcompleted.- Moving a job without
techIds(POST /v1/office/jobs/{id}/move) and a customer's reschedule (POST /v1/bookings/{id}/reschedule) keep the tech(s) already on the job when they still fit at the new time, instead of picking whoever is best then. - A named tech (
techIdson a move,techIdon a CrewConnect send or pass) is booked only if they have the skills the service needs; naming one person for a job that needs more fills the rest of the crew instead of sending them alone.
#2026-10-11 (eighteenth update)
Nothing here is breaking: every new field is added, and every old request still works.
#Office: Team
- The person object adds
designation(w2/1099, the same askind),address,availability(several windows a day),pendingChange(a tech's own change waiting for its day),maxTravelMiles,verified,taxId(masked: last 4 only) andphone. POSTandPATCH /v1/office/teamtakedesignation,address,availability,maxTravelMiles,verifiedandtaxId(stored encrypted;nullremoves it).POSTtakeshoursoravailability(one of them;hourswas required). Withavailability,hoursreads each day's envelope.- Several windows a day: a visit has to fit inside one; the gaps are never offered.
PUT /v1/office/team/{id}/availabilitynow stores several windows as sent and answers withavailability;mergedis always[](it listed weekdays joined into one window).409 slot_takenmessages foroutside_hoursname every window. - Max travel distance: a person is never offered a job farther than
maxTravelMilesfrom home. Picking them for one answers409 slot_takenwith reasonnot_on_teamand says how far it is. - New: documents per person (
GET/POST /v1/office/team/{id}/documents,POST .../{docId}/uploaded,DELETE .../{docId}), uploaded by presigned URL. Credentials take an optionaldocumentIdand return it. - New (owner only):
POST /v1/office/team/{id}/deactivate(off the schedule and signed out),/reactivate,/reset-sign-in.
#Office: Technician pay
- New: technician pay. Base pay per service (
basePayCents,paySpliton services, owner only) and per person (PUT/DELETE /v1/office/team/{id}/pay-rates/{serviceId}); mileage over each day's route atmileageRateCents(default 25¢ a mile); bonuses and deductions (POST/DELETE /v1/office/pay/adjustments); weekly periods closed and marked paid by the owner (POST /v1/office/pay/periods/{start}/close,/reopen,/paid); the report (GET /v1/office/pay,GET /v1/office/pay/lines) and its CSV (GET /v1/office/exports/pay.csv); settings (GET/PATCH /v1/office/pay/settings: mileage rate, week start,techsEditHours,techsSeePay). - Pay accrues when a job is marked done. A CrewConnect job you receive pays your tech on your rates; payouts between companies are unchanged.
- Two new KPIs:
tech_pay_per_jobandmileage_per_tech_day. The KPI list andkpis.csvhave two more rows.
#Field app
- New:
GET /v1/tech/pay(this week's pay so far, when the owner shows it) andGET/PATCH /v1/tech/profile(my hours and home address; changes take effect tomorrow). GET /v1/tech/meaddscompany.slug(for address search).
#2026-10-11 (seventeenth update)
#Intake and job search
- New: queue instead of refuse.
allowQueue: trueonPOST /v1/office/jobs, on a booking-page hold (POST /v1/public/{slug}/holds, withcustomer) and on a CrewConnect send (POST /v1/connect/connections/{id}/jobs) turns a409(time taken, too soon, or passed) into202withstatus: "unscheduled": the request waits in the company's intake queue with the time asked for and the reason, the customer is texted that a time is coming (new textrequest_queued), andrequest.createdgoes out withqueued: true. WithoutallowQueuenothing changes. See Intake and job search. - New: job statuses. Every job read here has
status(unscheduled,scheduled,en_route,on_site,done,cancelled) and two flags:overdue(the visit ended, not marked done) andneedsAttention(withattentionReason).GET /v1/office/schedulevisits addjobStatus,overdue,needsAttentionandattentionReason; nothing was removed. GET /v1/office/jobsgains the office's search:jobStatusandtab(what people see, the intake tabs),q(customer name, phone, address, city, external ids;#123for one job),filedFrom/filedTo,techId,serviceId,source,clientCompanyIdorclient,sort(updated_asc, the default and the order it had, orfiled_desc,filed_asc,start_desc,start_asc) andtotal. Every row (list and detail) addsjobStatus,overdue,overdueDays,city,state,customer.phone,ageDays,requested,requestedDays,intakeReason,source,sentBy,clientandfiledBy;statusmay now bequeued. People with a team or own scope get only their jobs here and in the detail (404otherwise), as on the board. Cursors from before this change are refused (422): start the walk again. Nothing else changed.- New:
GET /v1/office/intake(counts per tab and dispatch risks),GET /v1/office/jobs.csv(the list as CSV),GET /v1/office/intake/{id}/options(closest techs first with each open time and the drive to it) andPOST /v1/office/intake/{id}/discard. POST /v1/office/jobs/{id}/attention(andjob.needs_attention) is also raised by a tech'soffice_reviewcheck-in (source: "tech") and by a customer text that fails after three tries (source: "system", only when the job isn't flagged already).POST /v1/office/jobs/{id}/moveand/cancelalso take a job waiting in the intake queue.
#Field app
POST /v1/tech/jobs/{id}/checkintakesstatus: "office_review"with an optionalnote: it flags the job as needing attention and changes nothing on the schedule.
#Reports
- New:
GET /v1/office/reports/status(where the jobs filed in a range stand, the request funnel and cycle time) andGET /v1/office/reports/heatmap(jobs and techs per ~5 km area, by state and by branch).
#Users
- New:
GET /v1/me/prefsandPATCH /v1/me/prefs: each person's time zone (unset: the company's), theme and colour-vision filter, and what this sign-in is and when it ends. Any role. The company API key reads the defaults withstored: false.
#Other
- A customer text that fails after three tries now flags its job as needing attention. Text
kindaddsrequest_queued.
#2026-10-11 (sixteenth update)
#Client companies
- New: client companies, companies that file service requests into yours (an installer booking site surveys, say: the old app's "scheduling companies").
GET/POST /v1/office/clients,GET/PATCH /v1/office/clients/{id}(rename, email domains, turn on or off) andPOST /v1/office/clients/{id}/api-key(owner). A client sees only the requests it filed (status, time, the tech's first name) and your open times. See Client companies. - New, CrewConnect: connections have a
kind,partnerorclient.POST /v1/connect/connectionstakeskind: "client"to become a company's client. On a client connection,POST /v1/connect/connections/{id}/requestsfiles a request (pending, or booked at one of the provider's open times; never refused for lack of a time), andGET /v1/connect/requests,GET /v1/connect/requests/{jobId}andPOST /v1/connect/requests/{jobId}/cancelfollow them.POST .../jobson a client connection is409 client_connection. See Client connections. The connection object addskind: ignore it if you don't use clients. - Jobs record who booked them and where they came from.
GET /v1/office/schedulevisits addclient,filedBy(e.g.Filed by Dana One · Globex Solar) andhidden, and take?client=<id>or?client=none.GET /v1/office/requestslists client-filed requests too, each withclient,filedBy,sourceandnotes, and takes the sameclientfilter.GET /v1/office/kpistakesclient.POST /v1/office/jobstakesclientCompanyId.
#Roles, scopes and invitation links
- New role
field_manager: runs a team of dispatchers (teamLeadIdon the dispatcher), sees what the team filed, invites dispatchers to it. Everywhere a dispatcher may call, a field manager may too. - New per-user
scope:company,teamorown. Owners are alwayscompany; dispatchers default tocompany(toownat a client company); field managers toteam. People withteamorownscope see other jobs on the schedule as busy time (hidden: true), only their own requests, can change only jobs in their scope, and get403 scopedon whole-company endpoints (reports, KPIs, exports, money, settings, the live board, clients, webhooks). See Roles and scopes. Nothing changes for existing users: their default scope iscompany. - The user object adds
scope,scopeIsDefault,teamLeadIdandphone;rolemay befield_manager.POST /v1/office/userstakesscopeandteamLeadId. New:PATCH /v1/office/users/{id}(owner): role, scope, team, name, phone.GET /v1/office/meaddsscopeandteamLeadId. - New: invitation links.
POST /v1/office/invitationsreturns a one-timelink(/join/<token>, 7 days) and emails it when CrewLink sends email;GET /v1/office/invitations,POST .../{id}/revoke,POST .../{id}/resend. An email on a client's domain joins that client (matchedBy: "email_domain"). Accepting:GET /v1/public/invitations/{token}andPOST /v1/public/invitations/{token}/accept(a session, likePOST /v1/auth/verify). See Invitation links and sign-in.md.POST /v1/office/users/{id}/inviteis unchanged; field managers may also use it for their team.
#Webhooks
request.createdaddssource,clientandfiledBy(bothnullfrom the booking page).booking.createdhas new sourcesclientandapi(a client booking at your open times) withclientandfiledBy;booking.cancelledhas sourceclient. Ignore fields you don't use.
#2026-10-11 (fifteenth update)
Platforms: products built on CrewLink for the companies that grant them access. The integration guide is platforms.md. Nothing here is breaking.
#Platforms
- New key type:
Authorization: Bearer pl_<platform id>.<secret>withX-CrewLink-Company: <company slug or id>. It acts as that company's owner within the scopes the company granted (read,write,webhooks), and never reaches the field app, users and tokens, the API and integration keys, settings, the plan, payouts, payments, CrewConnect writes, messaging, wins, sign-in or/v1/me(403 forbidden). New errors:400 company_required,403 scope; a company that hasn't granted access is404. 1,200 requests a minute per key. Every call is logged. - New, owner only:
POST /v1/office/platforms/{slug}/grant{ scopes },DELETE /v1/office/platforms/{slug}/grant;GET /v1/office/platformslists them. - New, for a platform:
GET /v1/platform/grants;POST,GET,DELETE /v1/platform/webhooks(one subscription for every company that grantedwebhooks; its secret is shown once). GET /v1/office/webhooksendpoints addplatform(the slug of the platform that subscribed one for you, ornull).
#Office: jobs
- New:
GET /v1/office/jobs(by last change;status,from/to,updatedSince,externalSource/externalRef, cursor pages) andGET /v1/office/jobs/{id}(customer, visits, timeline, files, texts count). - New:
POST /v1/office/jobs/{id}/complete{ at?, notes?, silent? },POST /v1/office/jobs/{id}/notes,POSTandDELETE /v1/office/jobs/{id}/attention. - New:
GET /v1/office/catalog,POST /v1/office/availability(excludeJobId,leadMinutes) andPOST /v1/office/holds: the booking page's catalog, open times and holds behind a key, without per-IP limits. POST /v1/office/jobstakesholdId+holdSecret(book a held time), anaddressas parts ({ line1, line2, city, region, postcode, country, lat, lng }) and external ids, and answers withexternalRef,externalSourceandexternalRefs.serviceId,lat/lngand the time are no longer required when booking a hold. A platform key sees tech names as first name and last initial here and onPOST /v1/office/jobs/{id}/move.- External ids:
externalRefs: { "<source>": "<id>" }(or theexternalRef/externalSourceshorthand) onPOST /v1/office/jobs,POST /v1/public/{slug}/bookingsandPOST /v1/public/{slug}/requests; unique per company and source (409 duplicate_external_refwithjobId). Public callers can't use a granted platform's slug as a source (422 reserved_source). - Files on a job:
POST /v1/office/jobs/{id}/attachments(upload URL),.../attachments/{attachmentId}/uploaded,.../attachments/upload(direct, up to 20 MB),GETandDELETE. Techs read them:GET /v1/tech/jobs/{id}/attachments. - Services take
category(residential,commercial,otherornull), shown inGET /v1/public/{slug}and the office catalog.
#Office: team and time off
GET /v1/office/team?email=&externalSource=&externalRef=; people carryexternalRefs(settable onPOSTandPATCH /v1/office/team; anullid removes a source).- New:
PUT /v1/office/team/{id}/availability: the whole week, one or several windows a day (several are stored as one window from the first start to the last end for now;mergedsays which days).- Since the eighteenth update, several windows a day are stored as sent, and
mergedis always[].
- Since the eighteenth update, several windows a day are stored as sent, and
- New: bases,
POST,GETandDELETE /v1/office/team/{id}/bases: where someone starts and ends their days for a while (lodging);409 trip_conflict,409 base_conflict. A booked trip writes one too. - Blocks take
externalRefs(409 duplicate_external_refwithblockIdwhen one is sent again) andGET /v1/office/blocks?externalSource=&externalRef=finds them on any date.
#Idempotency
Idempotency-Keynow works on every write with a token (POST,PUT,PATCH,DELETEon office, CrewConnect and platform endpoints), not only the field app's. Keys are kept 24 hours per user, company key, or platform and company.
#Webhooks
- Every delivery body adds
companyId(the company the event is about). - Every event about a job adds
externalRef,externalSourceandexternalRefs. job.completedaddssource(field_app,apioroffice) andsilent(alwaysfalse); from the office or an integration,personIdisnull.- New events:
job.needs_attention,attachment.uploaded,person.updated,availability.updated,person.base.updated, and for platform-wide subscriptionsplatform.grant.createdandplatform.grant.revoked. booking.createdfrom a platform key hassource: "platform".
#Operations
- New:
GET /v1/ops/brain(owner only) shows the scheduling brain's training: CrewLink now tunes its scheduling settings per metro with a simulator (real building locations, road times and rush hours, plus real finished days, anonymised), and publishes a metro's new settings only when they beat the current ones without breaking any of the engine's guarantees. The response lists recent training runs with each metro's score before and after, the settings each metro plans with, and how many real days were learned from. No request or response changes elsewhere; open times may shift by a few minutes when your metro's settings change.
#2026-10-11 (fourteenth update)
#Wins (gamification)
- New: Wins, built only from what really happened (taps, check-ins, reviews, CrewConnect jobs). See Wins.
- Streaks lead.
GET /v1/tech/me/winsreturnsstreaks[]: on-time arrivals, on-the-way taps (days), five-star reviews and jobs inside the planned time, each withcurrent,best,startedOn,atRisk,pausedandnextMilestone.GET /v1/office/winsaddscompanyStreak(on-time days in a row against your goal) andlongestStreaks; each tech'sstreakaddsstartedOnandatRisk. Milestones (5, 10, 25, 50, 100, then every 100) are feed entries (kind: streak,team_streak) and celebrations. See Streaks. - Office:
GET /v1/office/wins?week=(a week's goals against your targets, the wins feed, each tech's on-time streak and badges),PATCH /v1/office/wins/settings(owner only: weekly goals for on-time rate, first-time fix, reviews and CrewConnect jobs sent; the leaderboard, off by default) andGET /v1/office/wins/leaderboard?week=(top 3 only, on on-time rate and five-star reviews). - Field app:
GET /v1/tech/me/wins(streak, this week, personal best, badges with progress, celebrations, the leaderboard's top 3 and your own row),PATCH /v1/tech/me/wins/settings(hide yourself from the leaderboard) andPOST /v1/tech/me/wins/celebrated. See field-app.md, Wins. - Badges with bronze, silver and gold tiers: Five-star, Early bird, Closer, One and done, Heads-up; Clockwork (the on-time streak) has five: 5, 10, 25, 50, 100.
- New webhook event
badge.earned. Endpoints subscribed to*start receiving it; ignore events you don't handle. See badge.earned. - The Monday report email gains a short Wins section (the company's on-time days in a row, how many techs kept their on-time streak all week, goals, badges earned) when there is something to say. The KPI lines are unchanged.
#2026-10-11 (thirteenth update)
National and global companies. Nothing changes for a company in one time zone and the US, except as noted.
#Time zones per branch
- Branches can have their own time zone:
timezoneonPOST /v1/office/branchesand the newPATCH /v1/office/branches/{id}(owner only;nullfollows the company). The branch object addstimezone,ownTimezone,countryandownCountry. Changing a branch's zone keeps every booked instant: its people's jobs from now on and their time off are re-expressed on the new clock (the answer'srezonedsays how many). - A job is on the clock of the branch serving its address, and a person's hours and day on their branch's. Open times, holds, bookings, office jobs, moves, emergencies, CrewConnect sends, out-of-area requests and offered times now answer with
timezone(the zone oflocal); the manage link's booking view,GET /v1/connect/sentand everybooking.*webhook carry it too. The person object addstimezone; so do the schedule's techs, blocks,GET /v1/tech/meandGET /v1/tech/day.GET /v1/office/schedule?date=gives each tech their own local day. - Office bookings, moves and request accepts take
local(YYYY-MM-DDTHH:mm, the wall clock where the job is) instead ofstart. Send one of the two (422with neither). PATCH /v1/office/settingstakestimezone(the company's default). Its answer andGET /v1/office/meaddtimezone,countryandunits.
#Countries, texts and address search
- Companies have a
country(ISO 3166-1 alpha-2, defaultUS;PATCH /v1/office/settings), branches may have their own. Texts go to mobile numbers in those countries (a US company with a London branch texts +1 and +44);SMS_COUNTRY_CODESon a server still overrides. A number without+and a country code is read the way the job's country writes it (020 7946 0958in the UK,0412 345 678in Australia). GET /v1/public/geocodewithslugsearches the company's countries (was: the US and Canada for everyone); withoutslugit now searches anywhere. Newcountryquery parameter adds one more country. Each match may carrycountryandlines(the address in its country's order:10115 Berlin,London/SW1A 2AA).
#Distances
- Companies have
units(miorkm;null= the country's usual one: miles in the US and UK). The API stays metric;GET /v1/public/{slug}andGET /v1/office/mesay which units to show. The web app and booking page show every distance and per-distance cost that way. - Availability adds
nearestKm: withoutsideArea, the straight-line km to the nearest branch.
#Drive times
- Breaking (owner-only ops view): rush-hour regions replace the 50 largest US metros. A region is one of ~390 listed metros worldwide or, outside them, a ~50 km grid cell, so every branch anywhere learns its own curve. Keys gained their country:
chicagois nowus-chicago(cell-<row>-<col>for cells), inyourMetroandmetrosofGET /v1/ops/travel. Do: if you read those keys, match the new ones. New:yourRegions(each branch's) androuter.regions(one road dataset per region the server serves, with its bounding box).
#2026-10-11 (twelfth update)
#Messaging
- New: Messaging, messages between the people of a company under
/v1/messaging: "Everyone" (the company channel), a thread per job, each tech's line to the office, direct messages, and a channel per CrewConnect connection between the two offices. List conversations with unread and @mention counts, page through messages, post (text, job notes, photos and PDFs), edit or delete your own for 15 minutes, mark read, mute, search, and pollGET /v1/messaging/updates?since=for what changed. Posts honourIdempotency-Keyand are limited to 60 a minute per person. - Who can message whom is one set of rules: the office (owners, dispatchers) can message anyone at the company; a tech can always message the office, and other techs only when the owner turns on
techsCanMessageTechs(PUT /v1/messaging/settings, owner only); only the office posts in the company channel unless the owner turns oneveryoneCanPostInMaster. A tech is in the thread of each job they're on. A CrewConnect partner sees only the threads of jobs sent between you and your partner channel. See the matrix. - Job threads fill in by themselves: booked, sent through CrewConnect, moved, on the way, arrived, done, check-ins and cancelled show as quiet
systemmessages, and a tech's job note (POST /v1/tech/jobs/{id}/notes) shows as anotefrom them. No change to those endpoints' requests or responses. - Messaging needs a person's token; the company API key gets
403 user_requiredexcept on the settings.
#Webhooks
- New event
message.posted: someone posted in the company channel, a job thread, an office line or a partner channel (never direct messages). Endpoints subscribed to*start receiving it; ignore events you don't handle.
#2026-10-11 (eleventh update)
#Users
- New:
GET /v1/me/tourandPATCH /v1/me/tour: where the signed-in person is in each in-app tour (step,completedAt,skippedAt), so CrewLink's apps start a tour once on first sign-in and resume it on any device. Any role, each person's own state only; the company API key reads an empty list and stores nothing (stored: false). See tour progress. Nothing else changed.
#2026-10-11 (tenth update)
CrewLink's plans are now priced by jobs (decided October 2026): Connect (free, 1099s who only take CrewConnect work), Independent $39 (75 jobs), Crew $149 (300), Shop $349 (800), Fleet $899 (2,500) and Enterprise $1,999 (6,000), yearly at 10 months for 12 with the jobs pooled. See Your CrewLink plan.
#Office
- Breaking: how jobs count. A job counts once, in the month it was booked, on the plan of the company that originated it: CrewConnect jobs you receive no longer count for you (they count for the sender), and a job passed on counts once, for the original sender. Jobs passed on (
passed) and bumped jobs waiting for a time (unscheduled) count; holds and cancelled jobs don't. Do: nothing, unless you mirrored the old rule. - Breaking: plan codes.
starter,teamandbusinessare gone;GET /v1/office/billingreturns the new codes and existing subscriptions are billed as Crew, Crew and Shop. A company with no plan chosen is oncrew(an individual onconnect).PUT /v1/office/billing/planrefuses the old codes with422. Do: useconnect,independent,crew,shop,fleetorenterprise. GET /v1/office/billingreturns the month's bill itemized (statement.lines: base, overage, best-price cap credit, waived first overage month, upgrade proration, free-trial credit), the payments through CrewLink with their fees by method (statement.fees), the subscription (status,trialEndsAt,hasCard,pending), every plan's numbers and the published rates.?period=YYYY-MM-DDshows another month; ended months are frozen.planandusagekeep their fields (planadds some). Not breaking.- New:
GET /v1/office/billing/usage, the live meter: used of included, days left, on pace for, where the cap starts, the next plan and its breakeven, an up/downnudge,contactSales, and the usage alerts sent this period. - New:
PATCH /v1/office/plan(owner): plan,interval(monthoryear) andspendAlertCents. Upgrades apply now with daily proration; downgrades and interval changes from the next period. Enterprise:422 contact_saleswithurl. - New:
GET /v1/office/exports/plan-usage.csv?period=, the jobs behind a month's bill, each markedincludedorover. - New:
POST /v1/office/billing/portal(Stripe Customer Portal),POST /v1/office/billing/checkoutandPOST /v1/office/billing/checkout/complete(put a card on file) for owners. - Usage alerts: at 75%, 90% and 100% of the included jobs, when the month is on pace for more than 110%, and when the projected bill passes your spend alert. Each fires once per period, by email to the owners and in the app.
- Independent and Connect accounts include two logins (owner + one helper): adding a third user, reactivating one past two, or inviting anyone but the first two active logins gets
422 plan_limit. - Scheduling is never blocked by the plan, overage, the trial or a failed payment.
#Payments
- Fees are one published all-in rate with CrewLink's 1% inside it: card 2.9% + 30¢, ACH 1% capped at $15 a payment. Each customer payment now records
feeCents(all-in),processingFeeCents(the provider's part),platformFeeCents(CrewLink's share, as before) andmethod(cardorach, set when paid). For card paymentsplatformFeeCentsis the same 1% as before. GET /v1/office/payouts: each payout addsinstantFeeCents, what an instant payout would cost (1%; standard payouts are free).
#Sign-up
- New, public:
GET /v1/public/plans,POST /v1/signup(company + owner + plan, returns a Stripe Checkout link: 30-day free trial, card on file; a Connect seat needs no card) andPOST /v1/signup/complete(the/welcomepage: confirms the checkout and signs the owner in once). See sign-up.md. - CrewLink's Stripe webhook also handles
checkout.session.completed,customer.subscription.created/updated/deleted,invoice.paidandinvoice.payment_failedfor the account's own subscription.
#2026-10-11 (ninth update)
#CrewConnect
POST /v1/connect/connections/{id}/jobsrefusals now carry areason, like office moves:too_soon(409too_soon),visibility(422invalid_request, atechIdon acompany-visibility connection), and on 409slot_takenone ofcredential(the people who could go don't hold the credentials you require),outside_hoursorno_capacity. Messages now say which: they name the partner company and the credentials, never its people. Status codes anderrorvalues are unchanged, so existing callers keep working; validation 422s have noreason. See send a job.- Docs: how to show a two-way partner as one entry (group
GET /v1/connect/connectionsbycompany.slug), and whyPOST .../terms/acceptshould echo theterms.proposedthe person saw. No API change. - Web app: the CrewConnect page shows one row per partner with both directions inside it; "Accept their terms" now sends the proposed terms (it posted no body and got 422); a refused send says why; a job your partner passed on can be taken back from the Sent tab.
#2026-10-11 (eighth update)
#Office: jobs
- New:
GET /v1/office/jobs/{id}/texts, a job's texts to the customer (confirmations, reminders, moves, on the way, review links, invoices, ...), newest first, with the number masked to its last 4 digits, the body, the status and when it went out. See job texts.
#2026-10-11 (seventh update)
#Office: users
- New:
POST /v1/office/users/{id}/inviteemails a user an invite: who added them, their role and what it lets them do, a link to the CrewLink web app and a 6-digit sign-in code already started for them (an ordinary sign-in code: 10 minutes, once). Link plus code signs them in. Owners invite anyone; dispatchers may invite techs. Call it again to resend. 5 invites a minute per company (429 rate_limited);409 inactive,422 no_email,502 mail_failed,503 no_mailer. See invite. POST /v1/office/userstakesinvite(boolean) and answers withinvited. With anemail, the new user is emailed an invite unlessinviteisfalse(the default istruewherever CrewLink sends email). Do: pass"invite": falseif you add users in bulk and invite them yourself later.- The user object (every
/v1/office/usersresponse) addsinviteSentAt: when the last invite went out, ornull.
#Sign-in
- Sign-in code emails are now branded HTML with a plain-text part; the subject is unchanged (
<code> is your CrewLink code). A sign-in started by an invite works like any other code withPOST /v1/auth/verify.
#2026-10-11 (sixth update)
#Drive times
- Open times, assignments and moves now count road drive times (OpenStreetMap) where CrewLink has road data for the area, times a rush-hour curve learned per metro from traffic-aware lookups, instead of a distance-based estimate. Expect start times to shift by a few minutes where roads are much longer or shorter than the straight line (rivers, highways). No request or response changes.
- New:
GET /v1/ops/travel(owner only) shows how drive times are worked out right now: road data or estimate, the road data's date, how much is answered from cache, today's traffic lookups against the cap, your metro and each metro's learned curve version.
#2026-10-11 (fifth update)
#Office
- New: Plan vs actual. CrewLink now records each day's plan at its cutoff (6:00 PM company time the evening before) and after every change from then on, and lines it up with the field app's taps.
GET /v1/office/brain/day?date=returns every tech's stops planned against worked (planned arrival, start and finish; real arrival and finish; minutes late with the Dispatch colour; the drive as planned, as Mapbox saw it at departure and as driven) and the day's numbers: on-time rate, CrewLink's and Mapbox's arrival error (also on the same drives only,paired), drive and on-site error, by tech, hour and service.GET /v1/office/brain/summary?from=&to=gives reviewed days as a series plus the range's totals. Days are reviewed every night;POST /v1/office/brain/review?date=reviews one now andPOST /v1/office/brain/snapshotrecords the plan on request. See Plan vs actual. - New:
GETandPATCH /v1/office/brain/settings(PATCH owner only): the daily budget of Mapbox lookups for your drives (travelLookupsPerDay, default 50,0turns them off) and today's use. - New CSV:
GET /v1/office/exports/plan-vs-actual.csv?from=&to=, one row per stop. See CSV exports.
#Webhooks
- New event
day.reviewed: a day's plan-vs-actual numbers, sent when the nightly review runs (once per day) and when the office asks for a review. Subscribe to it by name or with*. See day.reviewed. Endpoints subscribed to*start receiving it; ignore events you don't handle.
#Link previews
- New, public, no key:
GET /og/{kind}.png(default,book,appointment,offer,invoice,review,docs,notfound) andGET /og/company/{slug}.png(?kind=book|appointment|offer|invoice|review, defaultbook). Each is a 1200x630 PNG card in the CrewLink brand;?theme=lightfor the light one. Responses carry anETag(sendIf-None-Matchfor a304) andCache-Control: public, max-age=86400. A company card shows the company's name only. Unknown slug or kind:404; a badkindvalue:422. These are what CrewLink's own pages point theirog:imageat; you don't need them unless you build your own pages and want the same cards. - The web app's pages now carry link-preview tags (title, description, Open Graph, Twitter card, theme colours, robots), so a shared booking page shows "Book <company>" with that company's card in iMessage, Slack, LinkedIn, X, WhatsApp, Facebook and Google. Customer links (
/b,/o,/i,/r) show the business name only and arenoindex. A booking page for an unknown slug, a customer link with a wrong or expired token, and an unknown path now answer HTTP 404 (the page itself is unchanged)./docshas its own card. Not breaking: no/v1endpoint changed.
#2026-10-11 (fourth update)
#Booking page
GET /v1/public/geocodenow uses Mapbox when CrewLink has it set up (staging and production), with OpenStreetMap as the fallback while Mapbox is down. The response adds an optionalsource(mapboxorosm) saying which answered. Labels from Mapbox read like1600 West Belmont Avenue, Chicago, Illinois 60657, United Statesinstead of OpenStreetMap's longer form; showlabelas is and keep usinglat/lng. Not breaking: the request,resultsand limits are unchanged. See Address search.
#2026-10-11 (third update)
#Office
- Fix:
GET /v1/office/billingstarted a company's first period (before any plan change) on its creation date in UTC instead of in the company's time zone. A company created in the evening (US time) had its period start a day late, so that evening's jobs were left out ofusage.jobs. Periods now start on the local date, like every other date in the API.
#2026-10-11 (second update)
#Office
- When the office books, moves or accepts a job at a time that can't be booked, the 409 now says why.
POST /v1/office/jobs,POST /v1/office/jobs/{id}/moveandPOST /v1/office/requests/{id}/acceptaddreason(on_call_reserve,outside_hours,time_off,no_skills,not_on_teamordoesnt_fit) andpersonId, andmessagenames the cause, e.g. "That time is held for same-day emergencies (on-call reserve, 12:00–5:00 PM). Pick another time or tech." Before, a time inside the on-call window or outside a tech's hours was reported as "another job or the drive is in the way".erroris stillslot_taken. See Why a time can't be booked. POST /v1/office/emergencies:409 no_one_todayaddsreason(outside_hourswhen no one who can do it is working long enough today, with the last shift's end in the message;no_skills;doesnt_fit).- Breaking:
POST /v1/office/requests/{id}/accept: a time no one can get to in time is now409 cannot_reach(wasslot_taken"That time doesn't work anymore") with a message that explains the drive, e.g. a 3-hour drive before an 11:00 start means leaving before the tech's day starts. Every 409 from it carriesearliest, the earliest workable time that day (ornull). Do: if you matched onslot_takenhere, also handlecannot_reach. GET /v1/office/requests: each option hasdriveMinutes, the one-way drive from the tech's previous stop or home.addedDriveMinutesis unchanged: the extra driving that day, out and back, so about twice the one-way drive for a far-away job. ShowdriveMinutesas "the drive".- Breaking:
POST /v1/office/jobs/{id}/invoicerefuses a second invoice for a job while it has a draft, sent or paid one:409 invoice_existswith itsinvoiceId. Void it first to draft a new one. Do: look the current one up with the newGET /v1/office/jobs/{id}/invoice(404 when there's none). POST /v1/office/invoices/{id}/voidsetsbalanceCentsto0(depositAppliedCentsandtotalCentsstay). Void invoices, including ones voided earlier, showbalanceCents0everywhere, andGET /v1/public/invoices/{id}returnspayment: nullfor them.GET /v1/office/invoicesandGET /v1/office/jobs/{id}/invoiceincludecustomer(name) andservice.GET /v1/office/trips/suggestions: each stop hascustomerandaddress.POST /v1/office/tripsis unchanged and ignores them if you send the stops back as returned.- New:
GET /v1/office/blocks?from=&to=&personId=lists blocked time (office blocks and busy time from linked calendars) with each person's name, so you can find andDELETEthem. PATCH /v1/office/settingsreturns every setting as it is now, addingintegrationKey(whether one exists), the same values ascompanyinGET /v1/office/me.
#CrewConnect
POST /v1/connect/connectionstakes the partner's CrewConnect ID (their booking-page slug) or their whole booking link (https://go.crewlink.ai/book/acme), case-insensitively. Breaking: your own company is now422 self("You can't connect to your own company."; it was404 not_found). Messages say "CrewConnect ID" instead of "slug".
#2026-10-11 (first update)
#Sign-in
- New: passkeys. A signed-in person adds one with
POST /v1/auth/passkeys/optionsthenPOST /v1/auth/passkeys, lists theirs withGET /v1/auth/passkeysand removes one withDELETE /v1/auth/passkeys/{id}(session or user token; the company API key gets403 needs_user). Signing in:POST /v1/auth/passkeys/login/options(no key) thenPOST /v1/auth/passkeys/login, which returns the same session asPOST /v1/auth/verify. Both sign-in endpoints allow 10 requests a minute per IP. Passkeys work in the CrewLink web app only (they're bound to its site). See sign-in.md. POST /v1/office/users/{id}/tokenand/deactivatealso remove the user's passkeys.
#Booking page
- New: call the public booking endpoints straight from your own website. List your site's https origins with
PATCH /v1/office/settingsallowedOrigins(up to 20) and the API sends CORS headers to pages on those sites for/v1/public/{slug}and its availability, holds, bookings and requests, and for/v1/public/geocode?slug=. The homeowner's token links (/v1/bookings/{id}, offers, invoices, reviews) and/v1/openapi.jsonallow any origin. Preflight (OPTIONS) is supported. Office, field-app and CrewConnect endpoints send no CORS headers. See Calling from a browser. - New: the optional integration key, sent as
X-CrewLink-Key. With a valid key, holds, bookings, out-of-area requests and address search count per key instead of per visitor IP, at 300 a minute per endpoint and 50 live holds. It is public and safe in page code (it grants no data access); make or replace it withPOST /v1/office/integration-key/rotate(owner only, shown once). There is no key until you make one. Never put the company API key in a page. GET /v1/office/mereturnscompany.allowedOriginsandcompany.integrationKey(whether one exists).- Embed on your website now has the browser recipe: allowed origins, the integration key and the call sequence. Calling from your server still works the same way.
#2026-10-11
#Office
- New:
GET /v1/office/kpis?from=&to=returns every KPI (jobs, cancellations, on-time arrivals, hours booked, drive per visit, revenue, money collected, average ticket, parts margin, labor cost, profit margin, repeat visits, reviews, lead time, request response time, texts, CrewConnect jobs and payouts, emergencies, trips, time on site vs default) with the prior period, the change and a daily series, plus breakdowns by tech, service and branch.GET /v1/office/kpis/cataloglists the definitions. Ranges up to 366 days. See office.md. - New: CSV exports at
GET /v1/office/exports/{jobs,invoices,payments,kpis,techs,services}.csv?from=&to=(RFC 4180, UTF-8 with a byte order mark, local times, dollars) and the KPI report as a PDF atGET /v1/office/reports.pdf?from=&to=. - New:
PATCH /v1/office/settingstakesweeklyReportTo(an email, ornullto stop). Every Monday from 7:00 AM company time it gets last week's report PDF.GET /v1/office/meshows it.
#2026-10-10 (second update)
#Office
- New: manage your setup through the API.
GET/POST /v1/office/branches(service areas),GET/POST /v1/office/servicesandPATCH /v1/office/services/{id}(including turning a service off), andPOST /v1/office/teamto add a person.PATCH /v1/office/team/{id}also changesname,branchId,homeLat/homeLng,kindandhourlyCostCents. See office.md. GET /v1/office/teamreturns more per person:branchId,homeandhourlyCostCents.- New:
POST /v1/office/api-key/rotate(owner only) replaces the company API key and returns the new one once; the old one stops working at once. - New:
POST /v1/office/invoices/{id}/voidvoids a draft or sent invoice (409not_voidableonce paid). - New:
POST /v1/office/users/{id}/reactivate(owner only). Issuing a new token withPOST /v1/office/users/{id}/tokennow also ends the user's sign-in sessions, and deactivating a user ends them for good. POST /v1/office/jobs/{id}/moveand/cancelalso work on unscheduled jobs (moved off the board by an emergency or a long job). Moving one books it at the new time and texts the customer a fresh manage link.POST /v1/office/emergenciesreturns the customer'smanageToken, likePOST /v1/office/jobs.- Breaking: working hours must be whole minutes
0to1440with each day's start before its end; anything else is 422. Do: send[start, end]pairs like[480, 1020]. - Breaking:
GET /v1/office/reportsandGET /v1/office/trips/suggestionscover at most 92 days, withtoon or afterfrom(422 otherwise). Do: ask for longer periods in pieces. - Recurring work needs an active service (404 otherwise), and recurring visits of a turned-off service aren't booked.
- A trip is all or nothing: if any hand-off can't be made, nothing is handed off or booked (409
changed).
#Webhooks
- Every
booking.created,booking.rescheduledandbooking.cancellednow carries the same fields:bookingId,source,serviceId,service,start,localandtechs, plus a few per source. Cancellations say which techs were going. See webhooks.md. - Each stop of a booked trip sends
booking.createdwith sourcetripand itstripId. - Jobs moved because a tech is running late also send
booking.rescheduledwith sourcerunning_late.job.running_latelists techs by first name. trip.booked'stripIdis a string (UUID); the docs said integer.
#CrewConnect
- The connection object now includes
terms(agreed,proposed,proposedByYou), so either side can read the pay terms at any time. - A sender can ask again after a connection ended: the same connection goes back to
pending, keeping its terms. Ending an ended connection is a no-op (no second webhook). - Breaking:
POST /v1/connect/sent/{jobId}/cancelanswers 409too_lateonce the job is done or its start time has passed. It now also takes back a job your partner passed on, cancelling the job booked with their partner. - When a passed-on job is done, the company in the middle is paid by the original sender (on their terms) and pays its partner (on theirs).
- For services that need several people, open times count whole crews: a time is listed only when enough people are free together.
- The homeowner's manage page shows the brand the sender chose and tech first names only when the partner shares people.
#Field app
- Taps queued offline:
POST /v1/tech/jobs/{id}/stage,/checkin,/notes,/partsandPOST /v1/tech/locationtake an optionalat(when it happened, up to 24 hours back) and anIdempotency-Keyheader (a retry returns the first response instead of acting twice). See field-app.md. - A mid-job check-in on a job that isn't on your schedule today answers 409
no_visit_todayinstead of 500.changes[].techsare first names. - Unlinking a calendar removes its busy times from the schedule right away.
#2026-10-10
#Documentation
- New developer guides: overview, booking page, office, field app, CrewConnect, webhooks (with signature verification in Node, Python and PHP) and sign-in.
- The API serves its own interactive reference at
GET /docs, built from the OpenAPI 3 document atGET /v1/openapi.json. Every endpoint group now has a description.
#Booking page
- Breaking:
POST /v1/public/{slug}/holdsreturns aholdSecret, andPOST /v1/public/{slug}/bookingsrequires it. Without it the booking answers 422; with a wrong one, 404. Only whoever made a hold can book it. Do: keepholdSecretfrom the hold response (on your server) and send it with the booking. The hosted booking page already does. - One IP can have at most 3 live holds at a time (429
too_many_holds). A hold stops counting when it's booked or expires. - Address search (
GET /v1/public/geocode) allows one new lookup a second per IP (429slow_down) instead of one a second for everyone; cached answers are always served.
#Rate limits
- Per-IP limits on every endpoint: 600 requests a minute by default;
POST /v1/auth/start5,POST /v1/auth/verify10, public holds, bookings and out-of-area requests 30 each, address search 30. Over a limit the answer is 429rate_limitedwithRetry-AfterandX-RateLimit-*headers. See Rate limits.
#Sign-in
- Starting a sign-in always answers 202, before the account is looked up, so neither the answer nor its timing reveals whether an email has an account.
- After 10 wrong codes in 24 hours, code sign-in stops for that person until the oldest failure is a day old (still answered with 401
bad_code).
#Webhooks
- Breaking: adding (
POST /v1/office/webhooks) and removing (DELETE /v1/office/webhooks/{id}) endpoints is owner only; dispatchers get 403 and can still list them. Do: manage webhooks with the company API key or an owner's token. - Webhook URLs must be
httpson a public host name: no IP addresses, no internal names, nothing that resolves to a private or reserved address, and port 443 or 8443 unless the host resolves to a public address. Refused URLs answer 422invalid_url. The host is checked again before every delivery. - Redirects are never followed; a 3xx answer counts as a failed delivery. Do: subscribe the final URL.
#CrewConnect
- Breaking:
POST /v1/connect/connections/{id}/terms/accepttakes the terms you are accepting (kind,value,netDays) and answers 409terms_changedif they no longer match what the other side proposed. Do: send back the terms exactly as you showed them to the person accepting. - Proposing and accepting pay terms is owner only (403 otherwise) and needs an active connection (409
not_active). - Passing a job on (
POST /v1/connect/received/{jobId}/pass) follows the same rules as sending one: the connection's required credentials apply,techIdis accepted only when your partner shares people (422 otherwise), and the time must be at least an hour ahead (409too_soon).
#Texts
- Texts go to US and Canadian mobile numbers. Bookings with other numbers still succeed; they just aren't texted.