CrewLink API

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 reads jobStatus: "cancelled" with the new cancelledAfterDone: true (job reads; doneAt is kept). booking.cancelled carries wasDone: true. Nobody is texted unless notify: true is sent (a done job's default is false). KPIs and plan vs actual stop counting it. Each tech's pay for it is taken back and listed in the answer's payReversed: removed from a pay week that is still open, or a negative adjustment Job #<id> cancelled after completion in the open week when its week is closed or paid. GET /v1/office/pay/lines also takes jobId (every line for a job, any week).
  • New: services can be daylight only (daylightOnly on the service object, owners only; also in GET /v1/public/{slug} and GET /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 get 409 outside_daylight with 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), and GET /v1/public/share/{token} services carry daylightOnly. Owners can override with allowOutsideHours: true on POST /v1/office/jobs or 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_today for 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 is notify: false: notify controls homeowner texts and emails, never webhooks. Still no homeowner text and no tech push. (A kept double-booking under notify: false stays quiet, as before.)

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 with read): 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/clients stays 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/geocode takes share=<token> in place of slug.
  • 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", shareLinkId and bookedBy ({ repName, repEmail }; filedBy reads "Booked by Rita Rep via link · Globex Solar"); booking.created, request.created and every job webhook carry shareLinkId and bookedBy (null for other jobs); a site's timeline entry for such a visit has by: "rep"; GET /v1/office/clients/{id} jobs have bookedBy; source filters take share_link.
  • Your own domain: shareLinkHost and shareLinkMailFrom on PATCH /v1/office/settings (and in its answer), a platform-wide host (cli.ts platform-share-host), SHARE_LINK_HOSTS on the server. Link objects answer url (on that host when set) and fallbackUrl (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 -co on 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 team or own scope no longer read other people's merged sites (GET /v1/office/sites/{id} and GET /v1/office/sites?siteStatus=merged showed 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/users from a CrewLink staff act-as session no longer returns the new user's token (token is now optional in the answer; it is always there for owners and the company API key).
  • POST /v1/office/invoices/{id}/send answers texted: whether the text with the invoice link was queued. false when the job's homeowner texts are off (notify: false, the company's homeownerMessaging, 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/live and GET /v1/office/live now show location / position to 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 like GET /v1/office/dispatch/routes already did. Who sees a position.
  • Behavior change: on POST /v1/office/jobs, source: "import" and allowPast: true are owners only (an owner's sign-in, the company API key, an act-as session, a platform key with write); dispatchers and field managers get 403 owner_only, as they already did for allowOverlap and allowPast on 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) and POST /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 write on 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 send external_message.updated / .deleted to 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.completed for a silent completion is sent with silent: true to subscriptions with historyEvents: true (the table still said a silent completion sends no event). The OpenAPI description of POST /v1/office/jobs/{id}/move no longer says allowOverlap and allowPast are for imports only (owners, including platform keys with write, may use them on any job, as the rest of it says).
  • tools/qa/contract-live.mjs now 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, siteId on 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's force, forced in webhooks), blocks (92-day reads), bases, location trails and fixes (thinning, duplicates, off-the-clock drops, dispatch.position only when named), directions and the matrix, the dispatch reads (ETag / 304), webhooks read back through the delivery log (historyEvents opt-in, PATCH of events, retry), every kind of move, idempotency, peopleNames on a short grant, and { error, message } on every error.
  • The default enterpriseCallUrl (GET /v1/public/plans, 422 contact_sales on sign-up and plan changes) is now https://crewlink.ai/#walkthrough. It was https://crewlink.ai/#demo, an anchor the site doesn't have, so "Book a call" landed at the top of the home page. A server with ENTERPRISE_CALL_URL set 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/blocks from 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 answer 409 slot_taken with reason: time_off. Jobs already booked inside such a block are not moved; GET /v1/office/blocks lists the blocks to check.

  • Fixed: overdue for companies whose jobs are on several clocks. GET /v1/office/jobs?tab=overdue (and tab=scheduled), the counts and risks of GET /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's overdue flag 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 maxTravelMiles is 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) give prior: null and delta: null when 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-backfill re-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' updatedAt unchanged). Florida addresses split by the old rule and a street line repeated in a second town fold together; absorbed sites stay readable as merged.

#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 with allowPast, 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_attention event with source: "system", by: "closeout", except for jobs with notify: 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 handle job.needs_attention. No homeowner text, no tech push.
  • Webhook docs: job.needs_attention's source lists every value (api, office, tech, system) and by the 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 be ref:<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 be ref:<your message id>; delete leaves a tombstone), POST /v1/office/external-messages/backfill (up to 500 messages a call, original timestamps) and POST /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 url are 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 as sourceUrl, and copied says 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, tech or internal) with POST /v1/office/jobs/{id}/external-messages { channel, body } (409 no_platform when no platform owns it), and its author edits (PATCH, { body }) or deletes (DELETE) it (403 not_yours for anyone else).
  • New events external_message.created, external_message.updated, external_message.deleted: sent only to the owning platform's endpoints, with clientCompanyId, the job's ids, caseRef, threadKey and author (userId, personId, displayName as the grant's peopleNames says, role). Mirrored messages never echo back; a platform links its own id to a reply with crewlinkMessageId.
  • 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 the external_message.* events); GET /v1/office/sites/{id}/external-messages lists every visit's. caseRef stays 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} and PATCH /v1/office/webhooks/{id} now also take events, which replaces the endpoint's event list. The id, URL and signing secret stay the same. historyEvents is now optional; send either field or both. Use it to add dispatch.position or job.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, or from/to at most 24 hours apart): a tech's on-the-clock fixes (at, lat, lng, accuracy, heading, speed, source) and a summary: road distance (distanceMeters, miles, distanceMethod map_matched / segments / none), stops (3+ minutes within 75 m, matched to a visit within 150 m), per-visit arrivedAt / departedAt / dwellMinutes / plannedOrder / actualOrder, and plannedOrder / actualOrder. Owners and company-scope dispatchers; platform keys with read on a grant with peopleNames: "full" (403 on any other grant).
  • New: POST /v1/office/team/{id}/locations (platform keys with write): up to 500 fixes from the platform's own tech app, each within the last 7 days and not in the future (rejected per fix: too_old, in_future, invalid_point), deduplicated by (tech, at). On-the-clock fixes join the trail with source the 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): from to to or jobId, up to 23 waypoints, 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 (public pk. token only) only with includeMapStyle: true.
  • New: POST /v1/office/route-matrix (read): road distancesMeters, free-flow durationsSeconds and miles for up to 25 x 25 points (OSRM), and with planned: true the drive CrewLink's planning allows (plannedSeconds). For mileage and pay; 30 a minute per caller.
  • New setting: locationRetentionDays (7 to 365, default 90) on PATCH /v1/office/settings, in its answer and in GET /v1/office/me company. Trail fixes older than it are deleted by the hourly retention job.
  • Behavior change: POST /v1/tech/location and 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.position by 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 with read, see Routes): per working tech, position and heading, status, positionState (live, stale, none = location off, off_clock, hidden), navigating (following CrewLink's in-app directions), the route they 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) and stops[]. Filters date, 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 team or own scope 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 as acme), 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 own externalRefs), POST /v1/office/sites/{id}/merge and POST /v1/office/sites/{id}/split (audited; platform keys need write).
  • New on job reads (GET /v1/office/jobs, /jobs/{id}, intake): siteId and siteVisit ({ number, of, revisit }: "Visit 3 at this site"). New filter siteId on GET /v1/office/jobs.
  • New on POST /v1/office/jobs: optional siteId (404, 422 different_client); the 201 and 202 answers carry siteId.
  • 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 that siteId. Everything else still answers 409. Per-visit ids (sources ending in Visit, such as acmeVisit, and crewlink-v1) are never shared.
  • New on GET /v1/office/clients: sites and activeSites (the client's properties); jobs still counts visits.
  • New on GET /v1/tech/day stops: 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 their updatedAt is 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, default false) on every webhook endpoint: on POST /v1/office/webhooks and POST /v1/platform/webhooks (optional), and in their answers and lists.
  • New: PATCH /v1/office/webhooks/{id} and PATCH /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.rescheduled with history: true (a past start or a move to a past time written as history), booking.rescheduled with unassigned: true (unassign) and job.completed with silent: true (silent completion), are sent only to endpoints with historyEvents: true. Every existing endpoint starts with false, 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 with write) may send allowOverlap (with techIds) and allowPast on any job, not only imports. A skipped overlap answers doubleBooked and flags the job needs attention Double-booked with #N (quiet with notify: 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 allowOverlap or allowPast on a job that isn't an import get 403 owner_only (was 422 invalid_request). Owners no longer get 422 for 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 as unscheduled with intakeReason: "Unassigned", its visits are removed, no homeowner text, and booking.rescheduled goes out with techs: [], status: "unscheduled", unassigned: true, from and requested ({ 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 sends booking.rescheduled (or booking.created from the queue) with its techs and history: true; with notify: false it sent nothing. notify only controls homeowner texts, never webhooks.
  • Behavior change: POST /v1/office/jobs writing a past start as history (allowPast, source: "import") now sends booking.created with history: true (it sent no webhook).
  • Behavior change: POST /v1/office/jobs/{id}/complete with silent: true now sends job.completed with silent: true (it sent no event). silent still skips the review text and the payout. at may 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}/cancel with notify: false sends booking.cancelled (with techs) as always; POST /v1/office/jobs/{id}/history sends no webhook, deliberately (updatedAt moves, so updatedSince reads 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; branch and client filters, ETag/304, poll every 30 to 60 s.
  • New on GET /v1/office/dispatch: lanes carry fieldStatus, currentJobId, nextJobId and location.accuracy / heading / speed; stops carry risk (level on_time / at_risk / late, eta, minutesLate, reason traffic / previous_overrun / not_started / tight_schedule / unknown, basis live / chained / actual), enRouteAt, arrivedAt, completedAt, plannedOrder, actualOrder and actualDriveMinutes.
  • New on job reads (GET /v1/office/jobs/{id}): enRouteAt (with arrivedAt and doneAt).
  • 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) and job.risk_changed (before / after, evaluated every minute).
  • New: POST /v1/tech/location takes optional accuracy, heading and speed.
  • 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 and GET /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 (404 for another company's people) and name rules (per-grant peopleNames).
  • New: a company setting availabilityManagedBy (null or a platform slug), read and set with GET/PUT /v1/office/availability-management (owners; a platform key may claim or release it for itself) and shown in GET /v1/office/me company (with availabilityManagedByName). Per person: availabilityManagedBy on PATCH /v1/office/team/{id} (null, crewlink or a slug). Team reads carry availabilityManagedBy, availabilityManagedByName and ownAvailabilityManagedBy; GET /v1/tech/profile carries managedBy and managedByName.
  • 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. ...") on PUT /v1/office/team/{id}/availability, PATCH /v1/office/team/{id} with hours, availability, homeLat/homeLng, address, timezone or maxTravelMiles, POST/DELETE /v1/office/blocks, POST/DELETE /v1/office/team/{id}/bases and PATCH /v1/tech/profile. An owner may override one request with force: true (body) or ?force=true (DELETEs); it is recorded (new audit_events, listed as overrides). force from anyone else is 403. 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-crew is set to acme (release step; cli.ts platform-availability).
  • New: person.updated, availability.updated, block.created, block.deleted and person.base.updated carry source (platform, office, api, tech, admin, system), platform (the slug when a platform made the change) and forced, so an integration can skip its own echoes (webhooks.md). availability.updated and person.updated also carry availability (every window per day, the shape PUT …/availability takes) and person.updated carries maxTravelMiles; block.deleted carries the block's externalRefs and blockSource; block.created lists its timezone and externalRefs (they were sent but not documented).
  • New: POST /v1/office/team/{id}/bases answers 409 duplicate_external_ref with baseId when one of its externalRefs ids is already on a base of the company (a resent base used to be 409 base_conflict on the same days, or a second base on other days).
  • Docs: PUT /v1/office/team/{id}/availability answers availability in the request's shape (every window per day) next to hours (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 by id.
  • cli.ts default-hours skips people whose availability an integration owns.

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 carry lanes (lane guidance for its maneuver: indications, valid, and from Mapbox active and validIndication), and the route may carry maxspeed (posted limits, one per segment of geometry, { speed, unit } or null). Both are left out when the provider doesn't know them, so a step without lanes looks exactly as before.
  • mapStyle adds style (crewlink-nav: CrewLink's own navigation style, carried by the apps in light, blue and dark), version (1) and themes (["light", "blue", "dark"]). url and token are unchanged; apps that still read url keep 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/availability and 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 as POST /v1/office/first-available when 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), or enabled: false during a super admin's act-as session or when the company opted out.
  • New: PATCH /v1/office/settings takes productAnalytics (on, off), and its response includes it. off stops 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 or null) on PATCH /v1/office/settings (owner only) and in GET /v1/office/me company.
  • 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's reason (202), intakeReason and attentionReason on job reads and the dispatch board, and the same fields in webhook deliveries to a platform's endpoints (such as request.created's reason) 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/dispatch answers 422 invalid_request for a date that doesn't exist (2026-13-45, 2026-02-30); it used to answer an empty board.
  • Fixed: a time held with POST /v1/office/holds and then booked read source: "office" whoever held it; it now reads platform for a platform key, api for the company key and office for a signed-in person (and the job records who held it, so people with a team or own scope see their own). booking.created already said platform. Jobs booked before this release keep what they read.
  • Fixed: request.created for a booking queued instead of refused (allowQueue, such as an import whose tech is busy) carried externalRef but an empty externalRefs, and a filed request carried neither; every request.created now carries all three, like other job events. The delivery log's jobId filter also matches data.requestId, so it finds request.created.
  • New: test on POST /v1/office/team: add a test person in one call (was PATCH only; on POST it was ignored).
  • The OpenAPI spec now lists, on every route, the answers any call can give besides the route's own: 429 and 500 everywhere, 422 where a body, query or path is validated, 400 where there's a body, 401 and 403 on keyed routes, and 400 company_required and 404 on 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 lists 400 bad_request.
  • New for integrators and CrewLink QA: tools/qa/contract-live.mjs runs the platform flow against a server and checks every answer against the spec; apps/api/test/contract.test.ts does 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/switch needs 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) is 403 email_unproven. GET /v1/auth/companies and the companies of 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/requests and POST /v1/connect/connections/{id}/requests answer techs: [] (it listed first names); filedBy of a request the provider's office filed for the client is { "userId": null, "name": "<provider>" }; invitedBy of an invitation the provider made is the provider's name (also on GET /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 (visibility stays company).
  • 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 carry message.*, and a grant without webhooks stops the platform's company-level endpoints too.
  • New: grants carry peopleNames (short default, full): a platform that runs the company itself sees full names there (platforms.md). Set by the owner on POST/PATCH /v1/office/platforms/{slug}/grant, shown on GET /v1/platform/grants; your platform's grants on acme-crew and acme-test are full.
  • 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 (404 now).

#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}/send no longer answers 502 payments_unavailable for a company with no connected Stripe account (every company until Connect onboarding exists): the invoice is sent, the customer's link shows the balance with payment: null and asks them to pay the company directly. New field payOnline says whether the link offers a card payment. 502 now 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: null without 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 running release and uptimeSeconds; 503 when the database doesn't answer. For uptime monitors.
  • GET /v1/public/{slug} lists services in the order the company added them (it could change between loads).
  • The docs' base URL is https://go.crewlink.ai (api.crewlink.ai doesn'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/dispatch no longer lists a planned leg as tight when 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, so tight means 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 answered outsideArea: true next to that week's open times. A CrewConnect send for a base day is no longer refused out_of_area there.

#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/day has textsCustomer: whether CrewLink texts that job's customer. false when 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's true.
  • New: POST /v1/tech/jobs/{id}/stage answers texted: 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.

#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}/uploaded and POST /v1/tech/jobs/{id}/photos/{photoId}/uploaded answer 409 not_uploaded when nothing is at the upload URL yet (finish the PUT, then confirm again), and 422 upload_mismatch when 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_unavailable when storage didn't answer. A confirmed attachment's bytes (and a document's size) 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, from from=<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-turn steps (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) and mapStyle (a style URL and a public map token, or null). A job the tech isn't on is 404; up to 30 routes a minute per tech (429 rate_limited); 502 route_unavailable and 503 not_configured when 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/geocode results carry precision: rooftop, address, street (only the street: that house number wasn't found) or locality (an area). A label that lost the house number or the street direction you typed (9446 S Raymond Ave answered as Raymond Ave, Oak Lawn; 6530 W 79th St as 6530 79th St) gets them back.
  • Behavior change: POST /v1/office/jobs and POST /v1/public/{slug}/bookings take geoPrecision (that precision) and allowApproximate. A street or locality place without allowApproximate: true is 422 approximate_address. Leaving geoPrecision out is not checked, so integrations that send their own coordinates keep working; imports (source: "import") and platform keys default to allowApproximate: true.
  • New: jobs read geoPrecision (list, detail, intake): how exact the place is, or null when 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's onPaceFor now 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_taken with reason: 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) on PATCH /v1/office/settings (owner), in its response and in GET /v1/office/me company. Settings in the web app has it under Scheduling.
  • GET /v1/office/dispatch risks now 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, intakeReason and needs attention "Out of service area", job.needs_attention and request.created go out), the customer gets no text, and the due date moves on. POST /v1/office/recurring/run adds outOfArea: [{ 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}/jobs refuses an address outside every service area of the partner, or outside the area of the techId you picked, with 409 slot_taken and the new reason out_of_area (with allowQueue, 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, or null) and located (false for a branch with no usable location, which covers nothing). POST /v1/office/branches and PATCH /v1/office/branches/{id} take address (with lat/lng; PATCH address: null clears 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 techIds answers 409 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 a branchId was always required), so this only guards bad data.
  • Docs: POST /v1/office/jobs/{id}/move spells out multi-tech moves: without techIds a two-tech job keeps both (each checked; one who can't make it is replaced by the best fit), with techIds several people go, all checked, and the 409 names 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: stepMinutes on POST /v1/office/availability and POST /v1/office/first-available: 1, 5, 15 (default), 30 or 60. Start times lie on that grid of local minutes where the job is (60: whole hours only; 1: any minute, such as 08:07), each still one a tech can make with the drive and their other jobs. Any other value is 422 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/jobs with or without techIds, moves): before, without techIds such a time could be refused as 409 slot_taken even when a tech was free.

#2026-10-14 (thirty-third update: history corrections keep test people on cancelled imports)

  • POST /v1/office/jobs/{id}/history on 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 answer 201 with a new id where they used to answer 409 duplicate_external_ref. While a job that isn't cancelled holds it, it's still 409 duplicate_external_ref with that jobId. 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) with externalRef and no sort now orders jobs that aren't cancelled first, then the newest filed, so jobs[0] is the job the id points to now (all cancelled: the most recent). An id booked again lists every job that had it. sort is no longer defaulted in the OpenAPI schema; leaving it out is still updated_asc without externalRef.
  • 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 marked failed with lastError endpoint inactive or grant revoked (attempts unchanged) 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) and GET /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), deliveredAt and the payload as sent. Filters event, jobId (data.jobId or data.bookingId), status, limit (1 to 200, default 50), before (cursor; next on the answer). Newest first.
  • New: POST .../webhooks/{id}/deliveries/{deliveryId}/retry on 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 null there).
  • Every booking.created, booking.rescheduled and booking.cancelled now carries, besides what it had: jobId (the same number as bookingId), status (the job's status after the change, as on job reads; always cancelled on booking.cancelled), end and localEnd (the end of the first day's visit, or null with start), clientCompanyId and client ({ id, name } of the job's client company, or null; on every source now, not only client and api). bookingId and techs (integer tech ids) are unchanged. See webhooks.md and the platform example.
  • Docs: booking.created lists the platform and import sources.

#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}/move on an imported job (source: "import") to a time that has passed is no longer 409 in_the_past: allowPast is implied for imports (false turns it off; 422 on other jobs, whose past times stay 409 in_the_past). The job is booked as history with techIds (else the techs already on it; 422 with none): no availability, hours or overlap checks, real visits of the service's length, so it reads overdue until marked done. No homeowner text or reminder (queued ones are dropped); booking.created / booking.rescheduled unless notify: false. The answer adds history: true. POST /v1/office/jobs/{id}/history stays for cancelled and done imports.

  • New: allowOverlap: true on POST /v1/office/jobs/{id}/move for imported jobs (source: "import") with techIds (422 on any other job, without techIds, or with allowOutsideHours: false). An overlap with another visit of the tech is booked anyway instead of 409 tech_busy; time off, skills and the team are still checked. The answer adds doubleBooked ([{ personId, conflictJobId }]) when an overlap was skipped, and the moved job is flagged needs attention with the reason Double-booked with #<conflictJobId> (imported from the old app). With notify: false (or the job's texts off) the flag sends nothing (no job.needs_attention); otherwise it goes out as for any flag. Platform keys with write may 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 with source: "import" that is cancelled or done: sets its visit time and techs exactly as sent (no availability, hours, drive or overlap checks; end defaults to start plus the service's booked length), keeping its id, externalRefs and status (doneAt too). Nothing is sent (no homeowner text or email, no tech notification, no webhook); the timeline gets history_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_history for any other job.
  • Job reads of a cancelled import whose history was corrected show its old techs and end (any other cancelled job: techs: [], end: null, as before).
  • There is still no DELETE for 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/team takes skills: "all": every active service of the company, now and later (skills is kept equal to the services' bits when one is added, changed or turned off; skillsAll on team reads). It is the default for platform keys when skills is left out (it was 1), so a tech your platform adds is bookable for every service without a manual step; office keys and users still default to 1. PATCH /v1/office/team/{id} takes "all" too; a number turns it off.

  • New: homeLat/homeLng on POST /v1/office/team may be left out when branchId is sent: home is the branch's location (422 with neither).

  • New: POST /v1/office/requests for office keys, users and platform keys (write): the booking page's "request a visit" body (serviceId, lat, lng, customer, address, notes, requested) plus clientCompanyId (one of the provider's client companies, active; 404 otherwise, as on POST /v1/office/jobs), externalRefs / externalRef + externalSource (409 duplicate_external_ref with jobId) and notify. The request waits for the office (waiting); the client sees it on GET /v1/connect/requests as pending, and request.created carries client, filedBy and source (platform, office or api). POST /v1/public/{slug}/requests is 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}/move on an imported job (source: "import") with techIds places it at its old commitment the way an import is booked: allowOutsideHours (implied; false turns it off; 422 on 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 adds outsideHours: true; a real overlap with another visit is the 409 tech_busy (personId, conflictJobId) that POST /v1/office/jobs imports already give. Platform keys with write may do it. An ordinary move still clears outsideHours.
  • New: test clients and test people (office.md). test on client companies (PATCH /v1/office/clients/{id} { "test": true }, owner, kind client only) and on people (PATCH /v1/office/team/{id} { "test": true }, platform keys too); test on 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 optional clientCompanyId on POST /v1/office/availability, POST /v1/office/first-available and POST /v1/office/holds (kept on the hold) so a client's job is offered the right people; booking a hold may send clientCompanyId too.
  • Behavior change: once someone is marked test, real jobs are never offered or booked to them, and a named test tech is 409 slot_taken not_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/pay and its CSV, and the summary report. New includeTest=true on 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 (optional branch, client): everything the Dispatch page draws for one local day in one call. A lane per person working that day (tech with displayName, timezone, branchId, home, that day's hours windows; live status ok, tight, late, idle or on_job, doing and location; stops; blocks; utilization), each stop with its ids (externalRefs), client, service, customer, address in parts, times, state, stage, flags and driveMinutesBefore (the planned drive from the previous stop or home: the plan snapshot the techs are working to, else the engine's prediction; driveSource says which) and a live eta while the tech is on the way. Plus the intake queue (oldest first, with ageDays and intakeReason), risks (late, tight, overdue, aging, each with a message), counts and map pins.
  • 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 a team or own scope may call it: other people's jobs are busy blocks, with no live status.
  • Answers carry a weak ETag; send it back as If-None-Match and an unchanged board is 304 with 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: allowOutsideHours on POST /v1/office/jobs, imports only (422 without source: "import"; the default for imports with techIds, false turns 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 adds outsideHours: true. A tech already on another visit then is refused with the new 409 tech_busy (personId, conflictJobId; queued instead with allowQueue: true); time off, missing skills and people not on the team are still 409 slot_taken. Without source: "import", a time outside hours is 409 slot_taken outside_hours as before.
  • New: outsideHours on every job read (the job row: list, detail, intake, platform keys) and on GET /v1/office/schedule visits: true for 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 the status filter) reads a request with no visit as pending: one in the provider's intake queue used to read scheduled with start: null. scheduled now 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, and timezone on team reads, person.updated, availability.updated and PUT /v1/office/team/{id}/availability says which. Zones with the same clock are one (America/Indiana/Indianapolis reads America/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-available and the public availability give timezone and local in 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). With techIds on POST /v1/office/jobs, the techs' zone. start was always the true instant; store it.
  • New: timezone on POST /v1/office/team and PATCH /v1/office/team/{id} (platform keys too; IANA name, 422 for an unknown one; null on PATCH goes back to branch or home). Team reads add ownTimezone (what was set, or null) and serviceRadiusMiles. When a person's zone changes (their timezone, a home in another zone, another branch), their booked jobs and time off are re-expressed on the new clock keeping every instant; the PATCH answer adds timezone and rezoned: { jobs, blocks }.
  • New: defaultTechRadiusMiles on PATCH /v1/office/settings (owner) and in GET /v1/office/me company: how far from home people without a branch are offered work. A person's maxTravelMiles wins; unset, 40 km as before. A branch's people keep the branch's areaKm. outsideArea is 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: hoursSource on the person (default_recent_activity or null). 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, marked default_recent_activity (with person.updated and availability.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 for acme-crew.
  • What to do (your platform): read timezone from GET /v1/office/team or person.updated before sending PUT /v1/office/team/{id}/availability with a timezone (a different clock is 422 timezone_mismatch), and show local with the answer's timezone.

#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): a tech user 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 on POST /v1/office/team and PATCH /v1/office/team/{id} (null removes it; stored lower case), returned on every person (platform keys included; never on customer pages). phone is 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. New GET /v1/auth/companies and POST /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 answer 200 with the office app's link-preview tags instead of 404.
  • 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: deposit adds publishableKey (the Stripe publishable key for Stripe.js; null when the server has none, and the office takes the deposit) and testPay (development and test servers only). New texted: a confirmation text really went out; only then say "we texted you".
  • GET /v1/bookings/{id} (and the answer of POST /v1/bookings/{id}/reschedule) adds deposit (status due, paid or refunded, with what the browser needs to pay it while it's due) and texts (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=1 with 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} adds texts.
  • 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 (with STRIPE_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: false on POST /v1/office/jobs, POST /v1/office/holds (kept when the hold is booked) and POST /v1/office/jobs/{id}/move (stored on the job; a move with notify: true turns it back on). cancel (notify) and complete (silent) already had theirs.
    • Per company: homeownerMessaging: "on" | "off" on PATCH /v1/office/settings (owner; default on), shown in GET /v1/office/me.
    • Every job read (GET /v1/office/jobs, the detail, intake) and every GET /v1/office/schedule visit adds homeownerTexts (false: silenced). The hold answer adds notify.
  • No default schedule. POST /v1/office/team no longer needs hours or availability: someone added without either has no working hours (every day off) and is never offered until hours are set. Before, it answered 422.
  • earliestDay: "next_business_day" on POST /v1/office/availability (nothing before the next Monday-to-Friday day after today in the address's zone) and on POST /v1/office/holds, which also takes leadMinutes (default 0) and answers the new 409 too_soon for a start before either.
  • Breaking: a client company taking back a scheduled request (POST /v1/connect/requests/{id}/cancel) inside the provider's cancelCutoffHours (default 24) now gets 409 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.

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/users and POST /v1/admin/users/{id}/superadmin (promote or demote; never the last one: 409 last_super_admin), and GET /v1/admin/actions (the audit log).
  • GET /v1/office/me adds superAdmin (CrewLink staff signed in as themselves; always false for a company's own people) and actingAs ({ adminUserId, adminName, expiresAt } while CrewLink staff act as your owner, else null). company.kind can be platform (CrewLink's own company).
  • GET /v1/ops/travel and GET /v1/ops/brain are also open to super admins (platform-wide numbers).
  • POST /v1/auth/logout with an act-as token ends it and writes act_as_end to the audit log.
  • POST /v1/connect/connections answers 404 for partner: "crewlink", and GET /v1/public/crewlink is 404: 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.

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/jobs takes techIds (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" (implies allowPast; no confirmation, queued or reminder text; booking.created says source: "import") and filedAt (imports only: the old filed date, kept as createdAt). With allowQueue, 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/jobs with allowQueue now keeps its externalRefs (and externalRef, addressParts); before, the ids were dropped, so a retry booked a second job.
  • POST /v1/office/jobs/{id}/complete takes pay (default true; false: no technician pay line). For history (a job imported, or booked after its start), at may go back to the job's day instead of 30 days. Plan vs actual records the visit of the day of at, or the person's last visit before it.
  • POST /v1/office/jobs/{id}/cancel takes notify (default true; false: no cancellation text).
  • POST /v1/office/jobs: customer.phone may be left out for source: "import" and with the new contact: { kind: "commercial" | "office" }; no text is sent for such a job. New remind (default true, false for 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 platform write lets 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.

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/availability stays the booking page's answer with a key). See office.md.
  • GET /v1/office/schedule: each visit has state: held, scheduled, in_progress, overdue (the job's last visit ended and it isn't marked done) or completed.
  • 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 (techIds on a move, techId on 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 as kind), address, availability (several windows a day), pendingChange (a tech's own change waiting for its day), maxTravelMiles, verified, taxId (masked: last 4 only) and phone.
  • POST and PATCH /v1/office/team take designation, address, availability, maxTravelMiles, verified and taxId (stored encrypted; null removes it). POST takes hours or availability (one of them; hours was required). With availability, hours reads 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}/availability now stores several windows as sent and answers with availability; merged is always [] (it listed weekdays joined into one window). 409 slot_taken messages for outside_hours name every window.
  • Max travel distance: a person is never offered a job farther than maxTravelMiles from home. Picking them for one answers 409 slot_taken with reason not_on_team and 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 optional documentId and 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, paySplit on services, owner only) and per person (PUT/DELETE /v1/office/team/{id}/pay-rates/{serviceId}); mileage over each day's route at mileageRateCents (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_job and mileage_per_tech_day. The KPI list and kpis.csv have two more rows.

#Field app

  • New: GET /v1/tech/pay (this week's pay so far, when the owner shows it) and GET/PATCH /v1/tech/profile (my hours and home address; changes take effect tomorrow).
  • GET /v1/tech/me adds company.slug (for address search).

#2026-10-11 (seventeenth update)

  • New: queue instead of refuse. allowQueue: true on POST /v1/office/jobs, on a booking-page hold (POST /v1/public/{slug}/holds, with customer) and on a CrewConnect send (POST /v1/connect/connections/{id}/jobs) turns a 409 (time taken, too soon, or passed) into 202 with status: "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 text request_queued), and request.created goes out with queued: true. Without allowQueue nothing 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) and needsAttention (with attentionReason). GET /v1/office/schedule visits add jobStatus, overdue, needsAttention and attentionReason; nothing was removed.
  • GET /v1/office/jobs gains the office's search: jobStatus and tab (what people see, the intake tabs), q (customer name, phone, address, city, external ids; #123 for one job), filedFrom/filedTo, techId, serviceId, source, clientCompanyId or client, sort (updated_asc, the default and the order it had, or filed_desc, filed_asc, start_desc, start_asc) and total. Every row (list and detail) adds jobStatus, overdue, overdueDays, city, state, customer.phone, ageDays, requested, requestedDays, intakeReason, source, sentBy, client and filedBy; status may now be queued. People with a team or own scope get only their jobs here and in the detail (404 otherwise), 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) and POST /v1/office/intake/{id}/discard.
  • POST /v1/office/jobs/{id}/attention (and job.needs_attention) is also raised by a tech's office_review check-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}/move and /cancel also take a job waiting in the intake queue.

#Field app

  • POST /v1/tech/jobs/{id}/checkin takes status: "office_review" with an optional note: 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) and GET /v1/office/reports/heatmap (jobs and techs per ~5 km area, by state and by branch).

#Users

  • New: GET /v1/me/prefs and PATCH /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 with stored: false.

#Other

  • A customer text that fails after three tries now flags its job as needing attention. Text kind adds request_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) and POST /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, partner or client. POST /v1/connect/connections takes kind: "client" to become a company's client. On a client connection, POST /v1/connect/connections/{id}/requests files a request (pending, or booked at one of the provider's open times; never refused for lack of a time), and GET /v1/connect/requests, GET /v1/connect/requests/{jobId} and POST /v1/connect/requests/{jobId}/cancel follow them. POST .../jobs on a client connection is 409 client_connection. See Client connections. The connection object adds kind: ignore it if you don't use clients.
  • Jobs record who booked them and where they came from. GET /v1/office/schedule visits add client, filedBy (e.g. Filed by Dana One · Globex Solar) and hidden, and take ?client=<id> or ?client=none. GET /v1/office/requests lists client-filed requests too, each with client, filedBy, source and notes, and takes the same client filter. GET /v1/office/kpis takes client. POST /v1/office/jobs takes clientCompanyId.
  • New role field_manager: runs a team of dispatchers (teamLeadId on 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, team or own. Owners are always company; dispatchers default to company (to own at a client company); field managers to team. People with team or own scope see other jobs on the schedule as busy time (hidden: true), only their own requests, can change only jobs in their scope, and get 403 scoped on 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 is company.
  • The user object adds scope, scopeIsDefault, teamLeadId and phone; role may be field_manager. POST /v1/office/users takes scope and teamLeadId. New: PATCH /v1/office/users/{id} (owner): role, scope, team, name, phone. GET /v1/office/me adds scope and teamLeadId.
  • New: invitation links. POST /v1/office/invitations returns a one-time link (/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} and POST /v1/public/invitations/{token}/accept (a session, like POST /v1/auth/verify). See Invitation links and sign-in.md. POST /v1/office/users/{id}/invite is unchanged; field managers may also use it for their team.

#Webhooks

  • request.created adds source, client and filedBy (both null from the booking page). booking.created has new sources client and api (a client booking at your open times) with client and filedBy; booking.cancelled has source client. 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> with X-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 is 404. 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/platforms lists them.
  • New, for a platform: GET /v1/platform/grants; POST, GET, DELETE /v1/platform/webhooks (one subscription for every company that granted webhooks; its secret is shown once).
  • GET /v1/office/webhooks endpoints add platform (the slug of the platform that subscribed one for you, or null).

#Office: jobs

  • New: GET /v1/office/jobs (by last change; status, from/to, updatedSince, externalSource/externalRef, cursor pages) and GET /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, POST and DELETE /v1/office/jobs/{id}/attention.
  • New: GET /v1/office/catalog, POST /v1/office/availability (excludeJobId, leadMinutes) and POST /v1/office/holds: the booking page's catalog, open times and holds behind a key, without per-IP limits.
  • POST /v1/office/jobs takes holdId + holdSecret (book a held time), an address as parts ({ line1, line2, city, region, postcode, country, lat, lng }) and external ids, and answers with externalRef, externalSource and externalRefs. serviceId, lat/lng and the time are no longer required when booking a hold. A platform key sees tech names as first name and last initial here and on POST /v1/office/jobs/{id}/move.
  • External ids: externalRefs: { "<source>": "<id>" } (or the externalRef/externalSource shorthand) on POST /v1/office/jobs, POST /v1/public/{slug}/bookings and POST /v1/public/{slug}/requests; unique per company and source (409 duplicate_external_ref with jobId). 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), GET and DELETE. Techs read them: GET /v1/tech/jobs/{id}/attachments.
  • Services take category (residential, commercial, other or null), shown in GET /v1/public/{slug} and the office catalog.

#Office: team and time off

  • GET /v1/office/team?email=&externalSource=&externalRef=; people carry externalRefs (settable on POST and PATCH /v1/office/team; a null id 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; merged says which days).
    • Since the eighteenth update, several windows a day are stored as sent, and merged is always [].
  • New: bases, POST, GET and DELETE /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_ref with blockId when one is sent again) and GET /v1/office/blocks?externalSource=&externalRef= finds them on any date.

#Idempotency

  • Idempotency-Key now works on every write with a token (POST, PUT, PATCH, DELETE on 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, externalSource and externalRefs.
  • job.completed adds source (field_app, api or office) and silent (always false); from the office or an integration, personId is null.
  • New events: job.needs_attention, attachment.uploaded, person.updated, availability.updated, person.base.updated, and for platform-wide subscriptions platform.grant.created and platform.grant.revoked.
  • booking.created from a platform key has source: "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/wins returns streaks[]: on-time arrivals, on-the-way taps (days), five-star reviews and jobs inside the planned time, each with current, best, startedOn, atRisk, paused and nextMilestone. GET /v1/office/wins adds companyStreak (on-time days in a row against your goal) and longestStreaks; each tech's streak adds startedOn and atRisk. 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) and GET /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) and POST /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: timezone on POST /v1/office/branches and the new PATCH /v1/office/branches/{id} (owner only; null follows the company). The branch object adds timezone, ownTimezone, country and ownCountry. 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's rezoned says 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 of local); the manage link's booking view, GET /v1/connect/sent and every booking.* webhook carry it too. The person object adds timezone; so do the schedule's techs, blocks, GET /v1/tech/me and GET /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 of start. Send one of the two (422 with neither).
  • PATCH /v1/office/settings takes timezone (the company's default). Its answer and GET /v1/office/me add timezone, country and units.
  • Companies have a country (ISO 3166-1 alpha-2, default US; 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_CODES on a server still overrides. A number without + and a country code is read the way the job's country writes it (020 7946 0958 in the UK, 0412 345 678 in Australia).
  • GET /v1/public/geocode with slug searches the company's countries (was: the US and Canada for everyone); without slug it now searches anywhere. New country query parameter adds one more country. Each match may carry country and lines (the address in its country's order: 10115 Berlin, London / SW1A 2AA).

#Distances

  • Companies have units (mi or km; null = the country's usual one: miles in the US and UK). The API stays metric; GET /v1/public/{slug} and GET /v1/office/me say which units to show. The web app and booking page show every distance and per-distance cost that way.
  • Availability adds nearestKm: with outsideArea, 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: chicago is now us-chicago (cell-<row>-<col> for cells), in yourMetro and metros of GET /v1/ops/travel. Do: if you read those keys, match the new ones. New: yourRegions (each branch's) and router.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 poll GET /v1/messaging/updates?since= for what changed. Posts honour Idempotency-Key and 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 on everyoneCanPostInMaster. 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 system messages, and a tech's job note (POST /v1/tech/jobs/{id}/notes) shows as a note from them. No change to those endpoints' requests or responses.
  • Messaging needs a person's token; the company API key gets 403 user_required except 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/tour and PATCH /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, team and business are gone; GET /v1/office/billing returns the new codes and existing subscriptions are billed as Crew, Crew and Shop. A company with no plan chosen is on crew (an individual on connect). PUT /v1/office/billing/plan refuses the old codes with 422. Do: use connect, independent, crew, shop, fleet or enterprise.
  • GET /v1/office/billing returns 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-DD shows another month; ended months are frozen. plan and usage keep their fields (plan adds 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/down nudge, contactSales, and the usage alerts sent this period.
  • New: PATCH /v1/office/plan (owner): plan, interval (month or year) and spendAlertCents. Upgrades apply now with daily proration; downgrades and interval changes from the next period. Enterprise: 422 contact_sales with url.
  • New: GET /v1/office/exports/plan-usage.csv?period=, the jobs behind a month's bill, each marked included or over.
  • New: POST /v1/office/billing/portal (Stripe Customer Portal), POST /v1/office/billing/checkout and POST /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) and method (card or ach, set when paid). For card payments platformFeeCents is the same 1% as before.
  • GET /v1/office/payouts: each payout adds instantFeeCents, 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) and POST /v1/signup/complete (the /welcome page: 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.paid and invoice.payment_failed for the account's own subscription.

#2026-10-11 (ninth update)

#CrewConnect

  • POST /v1/connect/connections/{id}/jobs refusals now carry a reason, like office moves: too_soon (409 too_soon), visibility (422 invalid_request, a techId on a company-visibility connection), and on 409 slot_taken one of credential (the people who could go don't hold the credentials you require), outside_hours or no_capacity. Messages now say which: they name the partner company and the credentials, never its people. Status codes and error values are unchanged, so existing callers keep working; validation 422s have no reason. See send a job.
  • Docs: how to show a two-way partner as one entry (group GET /v1/connect/connections by company.slug), and why POST .../terms/accept should echo the terms.proposed the 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}/invite emails 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/users takes invite (boolean) and answers with invited. With an email, the new user is emailed an invite unless invite is false (the default is true wherever CrewLink sends email). Do: pass "invite": false if you add users in bulk and invite them yourself later.
  • The user object (every /v1/office/users response) adds inviteSentAt: when the last invite went out, or null.

#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 with POST /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 and POST /v1/office/brain/snapshot records the plan on request. See Plan vs actual.
  • New: GET and PATCH /v1/office/brain/settings (PATCH owner only): the daily budget of Mapbox lookups for your drives (travelLookupsPerDay, default 50, 0 turns 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.
  • New, public, no key: GET /og/{kind}.png (default, book, appointment, offer, invoice, review, docs, notfound) and GET /og/company/{slug}.png (?kind=book|appointment|offer|invoice|review, default book). Each is a 1200x630 PNG card in the CrewLink brand; ?theme=light for the light one. Responses carry an ETag (send If-None-Match for a 304) and Cache-Control: public, max-age=86400. A company card shows the company's name only. Unknown slug or kind: 404; a bad kind value: 422. These are what CrewLink's own pages point their og:image at; 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 are noindex. 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). /docs has its own card. Not breaking: no /v1 endpoint changed.

#2026-10-11 (fourth update)

#Booking page

  • GET /v1/public/geocode now uses Mapbox when CrewLink has it set up (staging and production), with OpenStreetMap as the fallback while Mapbox is down. The response adds an optional source (mapbox or osm) saying which answered. Labels from Mapbox read like 1600 West Belmont Avenue, Chicago, Illinois 60657, United States instead of OpenStreetMap's longer form; show label as is and keep using lat/lng. Not breaking: the request, results and limits are unchanged. See Address search.

#2026-10-11 (third update)

#Office

  • Fix: GET /v1/office/billing started 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 of usage.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}/move and POST /v1/office/requests/{id}/accept add reason (on_call_reserve, outside_hours, time_off, no_skills, not_on_team or doesnt_fit) and personId, and message names 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". error is still slot_taken. See Why a time can't be booked.
  • POST /v1/office/emergencies: 409 no_one_today adds reason (outside_hours when 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 now 409 cannot_reach (was slot_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 carries earliest, the earliest workable time that day (or null). Do: if you matched on slot_taken here, also handle cannot_reach.
  • GET /v1/office/requests: each option has driveMinutes, the one-way drive from the tech's previous stop or home. addedDriveMinutes is unchanged: the extra driving that day, out and back, so about twice the one-way drive for a far-away job. Show driveMinutes as "the drive".
  • Breaking: POST /v1/office/jobs/{id}/invoice refuses a second invoice for a job while it has a draft, sent or paid one: 409 invoice_exists with its invoiceId. Void it first to draft a new one. Do: look the current one up with the new GET /v1/office/jobs/{id}/invoice (404 when there's none).
  • POST /v1/office/invoices/{id}/void sets balanceCents to 0 (depositAppliedCents and totalCents stay). Void invoices, including ones voided earlier, show balanceCents 0 everywhere, and GET /v1/public/invoices/{id} returns payment: null for them.
  • GET /v1/office/invoices and GET /v1/office/jobs/{id}/invoice include customer (name) and service.
  • GET /v1/office/trips/suggestions: each stop has customer and address. POST /v1/office/trips is 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 and DELETE them.
  • PATCH /v1/office/settings returns every setting as it is now, adding integrationKey (whether one exists), the same values as company in GET /v1/office/me.

#CrewConnect

  • POST /v1/connect/connections takes 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 now 422 self ("You can't connect to your own company."; it was 404 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/options then POST /v1/auth/passkeys, lists theirs with GET /v1/auth/passkeys and removes one with DELETE /v1/auth/passkeys/{id} (session or user token; the company API key gets 403 needs_user). Signing in: POST /v1/auth/passkeys/login/options (no key) then POST /v1/auth/passkeys/login, which returns the same session as POST /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}/token and /deactivate also 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/settings allowedOrigins (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.json allow 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 with POST /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/me returns company.allowedOrigins and company.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/catalog lists 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 at GET /v1/office/reports.pdf?from=&to=.
  • New: PATCH /v1/office/settings takes weeklyReportTo (an email, or null to stop). Every Monday from 7:00 AM company time it gets last week's report PDF. GET /v1/office/me shows 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/services and PATCH /v1/office/services/{id} (including turning a service off), and POST /v1/office/team to add a person. PATCH /v1/office/team/{id} also changes name, branchId, homeLat/homeLng, kind and hourlyCostCents. See office.md.
  • GET /v1/office/team returns more per person: branchId, home and hourlyCostCents.
  • 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}/void voids a draft or sent invoice (409 not_voidable once paid).
  • New: POST /v1/office/users/{id}/reactivate (owner only). Issuing a new token with POST /v1/office/users/{id}/token now also ends the user's sign-in sessions, and deactivating a user ends them for good.
  • POST /v1/office/jobs/{id}/move and /cancel also 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/emergencies returns the customer's manageToken, like POST /v1/office/jobs.
  • Breaking: working hours must be whole minutes 0 to 1440 with each day's start before its end; anything else is 422. Do: send [start, end] pairs like [480, 1020].
  • Breaking: GET /v1/office/reports and GET /v1/office/trips/suggestions cover at most 92 days, with to on or after from (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.rescheduled and booking.cancelled now carries the same fields: bookingId, source, serviceId, service, start, local and techs, plus a few per source. Cancellations say which techs were going. See webhooks.md.
  • Each stop of a booked trip sends booking.created with source trip and its tripId.
  • Jobs moved because a tech is running late also send booking.rescheduled with source running_late. job.running_late lists techs by first name.
  • trip.booked's tripId is 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}/cancel answers 409 too_late once 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, /parts and POST /v1/tech/location take an optional at (when it happened, up to 24 hours back) and an Idempotency-Key header (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_today instead of 500. changes[].techs are 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 at GET /v1/openapi.json. Every endpoint group now has a description.

#Booking page

  • Breaking: POST /v1/public/{slug}/holds returns a holdSecret, and POST /v1/public/{slug}/bookings requires it. Without it the booking answers 422; with a wrong one, 404. Only whoever made a hold can book it. Do: keep holdSecret from 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 (429 slow_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/start 5, POST /v1/auth/verify 10, public holds, bookings and out-of-area requests 30 each, address search 30. Over a limit the answer is 429 rate_limited with Retry-After and X-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 https on 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 422 invalid_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/accept takes the terms you are accepting (kind, value, netDays) and answers 409 terms_changed if 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, techId is accepted only when your partner shares people (422 otherwise), and the time must be at least an hour ahead (409 too_soon).

#Texts

  • Texts go to US and Canadian mobile numbers. Bookings with other numbers still succeed; they just aren't texted.

CrewLink API · Reference · Changelog