CrewLink API

Webhooks

CrewLink can POST a signed JSON event to your server whenever something happens to a company's schedule: a booking is made, moved or cancelled, a tech is on the way, a job is done, a deposit or invoice is paid, a review comes in, a CrewConnect partner connects. Use them to keep your CRM, website or field tools in step without polling.

#Subscribing

Endpoints belong to a company. Adding and removing them needs the owner role (the company API key or an owner's token); dispatchers can list them. A platform with the webhooks scope can add its own endpoints for a company (listed with platform set to its slug, removed when the company revokes it), or keep one platform-wide subscription for every company that granted it (POST /v1/platform/webhooks). Each endpoint has its own secret, shown once when it is made.

#POST /v1/office/webhooks

Body Type Required Notes
url string yes https:// URL, up to 2000 characters (rules below)
events string[] yes At least one event name, or "*" for every event, including ones added later, except dispatch.position (name it to get it)
historyEvents boolean no Default false. true also sends this endpoint the history emissions (past times written as history, unassigns, silent completions). Change it later with PATCH
bash
curl -X POST $CREWLINK/v1/office/webhooks \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"url": "https://hooks.example.com/crewlink", "events": ["booking.created", "booking.rescheduled", "booking.cancelled"]}'

201

json
{
  "id": "6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93",
  "url": "https://hooks.example.com/crewlink",
  "events": ["booking.created", "booking.rescheduled", "booking.cancelled"],
  "active": true,
  "historyEvents": false,
  "platform": null,
  "secret": "whsec_<signing secret>"
}

secret is shown once. Store it; it signs every delivery to this endpoint.

URL rules, checked when you add the endpoint and again before every delivery:

  • https only, with a host name (not an IP address) and no user name or password in it.
  • The host must be on the public internet: names like localhost, *.local, *.internal, *.lan, *.corp and single-label names are refused, and so is any name that resolves to a private, loopback, link-local, carrier-grade NAT, multicast or reserved address.
  • Port 443 or 8443, unless the host already resolves to a public address (then any port).
  • Redirects are never followed. A 3xx answer counts as a failed delivery, so point the endpoint at the final URL.
Status error When
401 unauthorized No or bad token
403 forbidden Not an owner
422 invalid_request Not https, too long, unknown event name, empty events
422 invalid_url The URL breaks one of the rules above; message says which

#GET /v1/office/webhooks

Owner or dispatcher.

json
{
  "endpoints": [
    { "id": "6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93", "url": "https://hooks.example.com/crewlink", "events": ["booking.created", "booking.rescheduled", "booking.cancelled"], "active": true, "historyEvents": false, "platform": null }
  ]
}

#PATCH /v1/office/webhooks/{id}

Owner only (the company API key, an owner's token, or a platform key with webhooks for an endpoint that platform made). Changes one endpoint in place: its event list, and whether it gets the history emissions. The id, URL and signing secret stay the same. Deliveries already queued are not changed. Send either field or both.

Body Type Required Notes
events string[] no Replaces the event list (at least one; same values as when subscribing). "*" doesn't include dispatch.position, so name it, e.g. ["*", "dispatch.position"]
historyEvents boolean no true: this endpoint also gets the history emissions
bash
curl -X PATCH $CREWLINK/v1/office/webhooks/6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93 \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"historyEvents": true}'

200: the endpoint as listed, { "id", "url", "events", "active", "historyEvents": true, "platform" }.

Errors: 401, 403 forbidden (not an owner), 404 not_found (not this company's endpoint; on a platform key, not one that platform made), 422 invalid_request (neither field sent, an unknown event, or an empty list).

A platform's platform-wide subscription is changed with PATCH /v1/platform/webhooks/{id} (same body and answer, without platform).

#DELETE /v1/office/webhooks/{id}

Owner only. Removes the endpoint; deliveries still waiting for it are dropped.

json
{ "id": "6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93", "deleted": true }

Errors: 401, 403 forbidden, 404 not_found.

#GET /v1/office/webhooks/{id}/deliveries

Owner only. The endpoint's delivery log, newest first: every event queued for it, how many attempts were made, the last HTTP status and error, when it goes next, and the body exactly as sent. Use it to tell "never queued" from "queued but your server didn't answer 2xx". A platform key sees only the endpoints it subscribed; a platform's platform-wide subscription has the same log at GET /v1/platform/webhooks/{id}/deliveries. 404 not_found for an endpoint that isn't yours.

Query (all optional):

Name Notes
event Only this event, e.g. booking.created
jobId Only deliveries about this job: data.jobId, data.bookingId or data.requestId (request.created)
status pending (queued, or waiting to retry), delivered (a 2xx came back) or failed (given up after the last retry)
limit 1 to 200, default 50
before Cursor: pass next from the previous page
bash
curl -s "$CREWLINK/v1/office/webhooks/6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93/deliveries?jobId=1042" -H "Authorization: Bearer $KEY"
json
{
  "deliveries": [
    {
      "id": 58214,
      "endpointId": "6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93",
      "event": "booking.cancelled",
      "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
      "status": "pending",
      "createdAt": "2026-10-12T17:45:00.000Z",
      "attempts": 2,
      "lastAttemptAt": "2026-10-12T17:46:01.000Z",
      "nextAttemptAt": "2026-10-12T17:51:01.000Z",
      "lastStatusCode": 404,
      "lastError": "HTTP 404",
      "deliveredAt": null,
      "payload": { "id": 58214, "event": "booking.cancelled", "createdAt": "2026-10-12T17:45:00.000Z", "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "data": { "bookingId": 1042, "jobId": 1042, "source": "office", "status": "cancelled", "...": "..." } }
    },
    {
      "id": 58213,
      "endpointId": "6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93",
      "event": "booking.created",
      "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
      "status": "delivered",
      "createdAt": "2026-10-12T15:30:00.000Z",
      "attempts": 1,
      "lastAttemptAt": "2026-10-12T15:30:04.000Z",
      "nextAttemptAt": null,
      "lastStatusCode": 200,
      "lastError": null,
      "deliveredAt": "2026-10-12T15:30:04.000Z",
      "payload": { "id": 58213, "event": "booking.created", "...": "..." }
    }
  ],
  "next": null
}
Field Notes
attempts Sends tried so far (0 while it waits for its first)
lastAttemptAt When the last send was tried, or null
nextAttemptAt When it goes (again); null unless pending
lastStatusCode Your server's HTTP status on the last try; null when no answer came back (the host was refused because it resolves to a non-public address, a timeout, a connection error) or before the first try
lastError Why the last try failed (HTTP 503, HTTP 301 (redirects are not followed), Refused: ..., Timed out, ...), or why it was dropped unsent (endpoint inactive, grant revoked: see Retries), up to 500 characters; null after a success
payload The body as sent, with its id
next Pass as before for the next page; null on the last

Deliveries are kept; the log goes back to the endpoint's first event. Errors: 401, 403 forbidden (not an owner), 404 not_found.

#POST /v1/office/webhooks/{id}/deliveries/{deliveryId}/retry

Owner only. Queues one delivery again, due now, whatever its status (a delivered one is sent again too). It keeps its id and body; the send loop picks it up within about 10 seconds. attempts is kept, so one that had been given up and fails again is given up again (retry it again once your server is fixed). Answers the delivery as in the log (status: "pending"). Errors: 401, 403 forbidden, 404 not_found (no such endpoint, or no such delivery for it).

Rotating a secret: add a second endpoint with the same URL and events, accept signatures from either secret for a moment, then delete the old endpoint. During the overlap each event arrives twice (once per endpoint, with different delivery ids), so make sure your handler skips duplicates by booking or job as well as by delivery id.

#What a delivery looks like

Each event is one POST to your URL:

http
POST /crewlink HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
CrewLink-Event: booking.created
CrewLink-Delivery: 58213
CrewLink-Signature: t=1791817800,v1=5f0c3a1d9e8b7c6a5f4e3d2c1b0a99887766554433221100ffeeddccbbaa9988

{"id":58213,"event":"booking.created","createdAt":"2026-10-12T15:30:00.000Z","companyId":"8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b","data":{"bookingId":1042,"jobId":1042,"source":"booking_page","status":"booked","serviceId":2,"service":"Repair visit","start":"2026-10-13T19:30:00.000Z","local":"2026-10-13T14:30","end":"2026-10-13T21:00:00.000Z","localEnd":"2026-10-13T16:00","timezone":"America/Chicago","techs":[3],"clientCompanyId":null,"client":null,"externalRef":null,"externalSource":null,"externalRefs":{}}}
Header Meaning
CrewLink-Event The event name, same as event in the body
CrewLink-Delivery This delivery's id, same as id in the body. Unique per event per endpoint; the same on every retry
CrewLink-Signature t=<unix seconds>,v1=<hex HMAC-SHA256>; see below

Body:

Field Type Meaning
id integer The delivery id
event string The event name
createdAt string When it happened (ISO 8601, UTC). Retries keep the original time
companyId string The company the event is about (useful when one endpoint serves several companies, as a platform's does)
data object The event's fields, listed under Events. On a platform's endpoints (platform-wide or one it made for a company), the worded fields (message, reason, intakeReason, attentionReason) name techs as "First L."; the company's own endpoints get full names

Answer with any 2xx within 10 seconds. Anything else (3xx, 4xx, 5xx, a timeout, a refused connection) is a failed attempt and is retried. Do the real work after you answer: put the event on a queue, return 200, then process it.

Ids in data (bookingId, jobId, techs, personId) are the same ids the rest of the API uses. techs and personId are tech ids from GET /v1/office/team.

#Verifying signatures

Every delivery is signed with the endpoint's secret so you can tell it came from CrewLink and wasn't changed:

CrewLink-Signature: t=1791817800,v1=5f0c3a1d...
  • t is the Unix time (seconds) when this attempt was sent. It changes on each retry.
  • v1 is the hex HMAC-SHA256 of the string "<t>.<raw body>", keyed with the whole secret (including the whsec_ prefix).

To verify:

  1. Read the raw request body bytes. Don't parse and re-serialize the JSON first: any change in spacing or key order breaks the signature.
  2. Split the header on , and each part on the first =, and take t and v1.
  3. Compute HMAC-SHA256 of t + "." + rawBody with your secret, as lowercase hex.
  4. Compare it with v1 in constant time.
  5. Reject the delivery if t is more than 5 minutes from your clock, so a captured request can't be replayed later.

Answer 400 or 401 when verification fails; CrewLink will retry, which is what you want if the failure was on your side.

#Node.js

js
// npm i express
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.CREWLINK_WEBHOOK_SECRET; // whsec_...

export function verifyCrewLink(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the body as a Buffer, exactly as sent.
app.post('/crewlink', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyCrewLink(req.body, req.get('crewlink-signature'), SECRET)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // answer first
  if (seen.has(event.id)) return; // a retry of something already handled
  seen.add(event.id);
  queueMicrotask(() => handle(event));
});

function handle(event) {
  switch (event.event) {
    case 'booking.created':
      console.log('new booking', event.data.bookingId, 'at', event.data.local);
      break;
    case 'booking.cancelled':
      console.log('cancelled', event.data.bookingId);
      break;
  }
}

app.listen(3000);

#Python

python
# pip install flask
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

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

def verify_crewlink(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

@app.post("/crewlink")
def crewlink():
    raw = request.get_data()  # the raw bytes; don't use request.json before verifying
    if not verify_crewlink(raw, request.headers.get("CrewLink-Signature"), SECRET):
        abort(401)
    event = json.loads(raw)
    # enqueue event for processing, skipping ids you've already handled
    print(event["event"], event["data"])
    return "", 200

#PHP

php
<?php
// crewlink.php
$secret = getenv('CREWLINK_WEBHOOK_SECRET'); // whsec_...

function verify_crewlink(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool {
    if (!$header) return false;
    $parts = [];
    foreach (explode(',', $header) as $part) {
        $kv = explode('=', trim($part), 2);
        if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
    $t = (int) $parts['t'];
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

$raw = file_get_contents('php://input');
if (!verify_crewlink($raw, $_SERVER['HTTP_CREWLINK_SIGNATURE'] ?? null, $secret)) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
http_response_code(200);
// process $event['event'] and $event['data'], skipping $event['id'] values already handled

#Retries

A failed attempt is retried on this schedule:

Attempt When
1 Right away (within about 10 seconds of the event)
2 1 minute after attempt 1 failed
3 5 minutes after attempt 2
4 30 minutes after attempt 3
5 2 hours after attempt 4
6 12 hours after attempt 5

After the sixth failure (about 14.5 hours after the event) that delivery is given up. Other deliveries to the same endpoint carry on independently; an endpoint is never switched off for failing.

Deliveries still waiting when their endpoint is turned off (a platform's endpoints for a company are, when the company revokes the platform) or, for a platform-wide subscription, when the company no longer grants the platform webhooks, are dropped: never sent, marked failed with lastError endpoint inactive or grant revoked and attempts unchanged. platform.grant.* events still go out.

Every attempt is in the endpoint's delivery log (status code, error, next try), and any delivery can be sent again once your server is fixed.

If your endpoint was down longer than that, reconcile from the API: the office schedule shows every job as it is now.

#Ordering and duplicates

  • Duplicates happen. If your server handled an event but the answer didn't reach CrewLink (a timeout, a dropped connection), the same delivery comes again with the same id. Store processed ids and skip repeats.
  • Order isn't guaranteed. Deliveries go out oldest first, but a retry can land after a newer event: a booking.rescheduled can arrive before the booking.created it follows if the first attempt of booking.created failed. Use createdAt to order events about the same booking, and ignore an event older than the last one you applied.
  • Events are notifications, not a log you replay. When you need the current truth (for example after an outage), read it from the API.
  • One event per endpoint. Two endpoints subscribed to the same event each get their own delivery with their own id.

#Events

Event When
booking.created A job is booked from any source
booking.rescheduled A booking moves to another time or tech
booking.cancelled A booking is cancelled
block.created Time off or another block is put on a tech's calendar
block.deleted A block is removed
job.en_route The tech is on the way
job.arrived The tech arrived
job.completed The tech finished
job.running_late The tech said the job will run long; later jobs were handled
job.risk_changed A stop's risk level (on time, at risk, late) changed
dispatch.position A tech moved or their field status changed, on the clock (throttled). Not in *: subscribe by name
deposit.paid A booking deposit was paid
invoice.paid An invoice was paid
review.created A homeowner left a review
request.created A request is waiting for a time: out-of-area from the booking page, filed by a client company, or queued instead of refused (queued: true)
trip.booked The office booked a trip that serves out-of-area requests
day.reviewed A day's plan was compared with what happened (each night, or on request)
badge.earned A tech reached a new badge tier (Wins)
connection.requested A company asked to connect with you on CrewConnect
connection.accepted A partner accepted your connection request
connection.ended The other side ended a connection
message.posted Someone posted in the company channel, a job thread, an office line or a partner channel (never direct messages). Never sent to a platform's endpoints
job.needs_attention The office or an integration flagged a job
attachment.uploaded A file (proposal, photo, document) was added to a job
person.updated A tech was added or changed
availability.updated A tech's weekly hours changed
person.base.updated A tech's base (lodging) was added or removed
platform.grant.created, platform.grant.revoked A company granted or revoked a platform (platform-wide subscriptions only)
site.created A property got its first visit (for that client company)
site.updated A site became active or idle, was merged into or split, or its ids changed
site.merged Another site was merged into this one
external_message.created, .updated, .deleted The office answered in a job's mirrored conversation, or edited or deleted its answer (only to the platform that owns it; never to the company's own endpoints)

Integrators' ids. Every event about a job (booking.* with bookingId, job.*, attachment.uploaded, request.created, ...) also carries externalRef, externalSource (the job's primary external id, or null) and externalRefs (all of them, { "<source>": "<id>" }, empty when none). See platforms.md.

Sites. Every event about a job (a visit) also carries siteId: the site, the property for its client company with every visit ever made there, it belongs to (null only for one not linked yet, such as a hold). The visit's own fields keep their meaning.

Platforms. A platform can keep one subscription that gets the events of every company that granted it the webhooks scope; companyId in the body says which. See platforms.md.

Events go to the company whose schedule changed. In CrewConnect, a job a sender passes to a partner is on the partner's schedule, so the partner gets its booking.created, stage and payment events; the sender gets the connection.* events addressed to it. Times in data follow the usual conventions: start is an instant, local and from are the wall clock where the job is (booking events carry its zone in timezone: the branch's, which is the company's unless the branch has its own).

#Booking events

booking.created, booking.rescheduled and booking.cancelled share one shape. Every one carries these fields, read from the booking as it is after the change:

Field Type Meaning
bookingId integer The job. The same id as in the office API and the customer's manage link
jobId integer The same id as bookingId (every other job event calls it jobId)
source string What caused it (tables below)
status string The job's status after the change, as status on job reads: booked for a timed booking, unscheduled or queued while it has no time, cancelled on every booking.cancelled
serviceId integer Your service
service string The service's name
start string or null The start as an instant (ISO 8601, UTC). null while the job has no time (needsCall)
local string or null The same start on the job's wall clock, YYYY-MM-DDTHH:mm. null with start
end string or null When the first day's visit ends, as an instant (the latest end among the techs, as end on job reads). null with start, or when nobody is on the job
localEnd string or null The same end on the job's wall clock
timezone string The IANA zone of local and localEnd (the zone of the branch serving the job)
techs integer[] Ids of the people going, in id order. For booking.cancelled, who was going. Empty while the job has no time
clientCompanyId string or null The client company the job is for, whoever booked it; null when none
client object or null { id, name } of that client company; null when none. Present on every source, not only client and api

Some sources add fields, listed with each event.

#booking.created

A job was booked.

source Booked by Extra fields
booking_page A homeowner online
office The office
emergency The office, as a same-day emergency
recurring The recurring-job scheduler recurrenceId
out_of_area_request The office, accepting an out-of-area request tripFeeCents (or null)
trip The office, booking a trip: one event per stop tripId
crewconnect A CrewConnect partner sending (or passing on) work to you sentBy (the sending company's name)
client A person at one of your client companies, booking at one of your open times filedBy ({ userId, name })
api A client company's API key, booking at one of your open times filedBy (null)
platform A platform with its key, on POST /v1/office/jobs history: true for a past start written as history
import A job moved from another system (source: "import" on POST /v1/office/jobs) history: true for a past start written as history
share_link A client company's sales rep, through one of that client's share links (no account) clientCompanyId and client (that client), shareLinkId, bookedBy ({ repName, repEmail })
office (from the intake queue, a past time) A queued job moved to a time that has passed (an import, or an owner's allowPast) history: true

office and api add history: true too when POST /v1/office/jobs writes a past start as history (allowPast).

shareLinkId and bookedBy (every job event, added 2026-10-13). Every event about a job (booking.*, request.created, job.*, attachment.uploaded and the rest that carry jobId or requestId) also carries shareLinkId (the client share link it was booked or requested through) and bookedBy ({ "repName": "Rita Rep", "repEmail": "rita@globex.example" }, the client's sales rep who used it). Both are null for every other job. They are additive: nothing else changed.

json
{
  "id": 58213,
  "event": "booking.created",
  "createdAt": "2026-10-12T15:30:00.000Z",
  "data": {
    "bookingId": 1042,
    "jobId": 1042,
    "source": "booking_page",
    "status": "booked",
    "serviceId": 2,
    "service": "Repair visit",
    "start": "2026-10-13T19:30:00.000Z",
    "local": "2026-10-13T14:30",
    "end": "2026-10-13T21:00:00.000Z",
    "localEnd": "2026-10-13T16:00",
    "timezone": "America/Chicago",
    "techs": [3],
    "clientCompanyId": null,
    "client": null,
    "externalRef": null,
    "externalSource": null,
    "externalRefs": {},
    "siteId": 311,
    "shareLinkId": null,
    "bookedBy": null
  }
}

The same booking made by a client's rep through a share link:

json
{
  "event": "booking.created",
  "data": {
    "bookingId": 1043,
    "jobId": 1043,
    "source": "share_link",
    "status": "booked",
    "service": "Site survey",
    "clientCompanyId": "7d0c4c1e-2b7a-4b8e-9a51-3f1f8a2c6b90",
    "client": { "id": "7d0c4c1e-2b7a-4b8e-9a51-3f1f8a2c6b90", "name": "Globex Solar" },
    "shareLinkId": "c1a5e7f2-9b3d-4c8e-a6f0-2d4b8e1c7a93",
    "bookedBy": { "repName": "Rita Rep", "repEmail": "rita@globex.example" }
  }
}

A trip also sends trip.booked with all its stops.

#booking.rescheduled

A booking moved to another time or tech. from is the old local start when there was one.

source Moved by Extra fields
customer The homeowner, from the manage link from
customer_choice The homeowner, picking one of the times offered after the company had to move it from
office The office: a drag on the board, or booking a job that had no time from (null for a job that had no time)
office with history: true A move to a time that has passed, written as history (an import, or an owner's allowPast). Sent with notify: false too from, history
office with unassigned: true The office unassigned the job (techIds: []): it is back in the intake queue. status is unscheduled, techs is [], start and local are null from, unassigned, requested ({ start, local }: the time it keeps)
emergency An emergency took the tech; CrewLink found a new time from
emergency with needsCall: true An emergency took the tech and no time could be found; the homeowner was texted a choice of times, or will be called. start and local are null from, needsCall
running_late The tech ran long (job.running_late) and the job went to a teammate or to a new time from
running_late with needsCall: true The tech ran long and nothing fit within a week. start and local are null from, needsCall
trip_handoff A teammate takes the job at the same time so its tech can go on a trip
json
{
  "id": 58240,
  "event": "booking.rescheduled",
  "createdAt": "2026-10-12T16:02:11.000Z",
  "data": {
    "bookingId": 1042,
    "jobId": 1042,
    "source": "customer",
    "status": "booked",
    "serviceId": 2,
    "service": "Repair visit",
    "start": "2026-10-15T14:00:00.000Z",
    "local": "2026-10-15T09:00",
    "end": "2026-10-15T15:30:00.000Z",
    "localEnd": "2026-10-15T10:30",
    "timezone": "America/Chicago",
    "techs": [5],
    "clientCompanyId": null,
    "client": null,
    "externalRef": null,
    "externalSource": null,
    "externalRefs": {},
    "from": "2026-10-13T14:30"
  }
}

#booking.cancelled

source Cancelled by Extra fields
customer The homeowner, from the manage link
office The office reason (or null); wasDone: true when the job was already done (its techs' pay was taken back, and it no longer counts as done)
crewconnect The CrewConnect sender who sent you the job (also sent to a company that passed the job on, for its own copy) by (the sender's company name)
client One of your client companies, taking back a request it filed by
json
{
  "id": 58251,
  "event": "booking.cancelled",
  "createdAt": "2026-10-12T17:45:00.000Z",
  "data": { "bookingId": 1042, "jobId": 1042, "source": "office", "status": "cancelled", "serviceId": 2, "service": "Repair visit", "start": "2026-10-15T14:00:00.000Z", "local": "2026-10-15T09:00", "end": "2026-10-15T15:30:00.000Z", "localEnd": "2026-10-15T10:30", "timezone": "America/Chicago", "techs": [5], "clientCompanyId": null, "client": null, "externalRef": null, "externalSource": null, "externalRefs": {}, "reason": "Customer called to cancel" }
}

#History emissions (opt-in: historyEvents)

Some events describe the past, or a change a receiver may not want to tell anyone about: a time that has already passed written as history, a job taken off its techs, and a completion that deliberately sent the customer nothing. An integration that turns every booking.rescheduled into a "your appointment moved" message would otherwise send one about a visit that already happened. So these four go only to endpoints that opted in with historyEvents: true:

Emission Marked in data by
booking.created for a past start written as history (POST /v1/office/jobs with allowPast or source: "import", or a move of a queued job to a past time) history: true
booking.rescheduled for a move to a past time (history) history: true
booking.rescheduled for an unassign (techIds: [] on a move) unassigned: true
job.completed for a silent completion (POST /v1/office/jobs/{id}/complete with silent: true) silent: true
  • historyEvents is false by default: for every endpoint that existed before it was added, and for new ones unless you send historyEvents: true when you subscribe (or POST /v1/platform/webhooks). Turn it on or off with PATCH /v1/office/webhooks/{id} or PATCH /v1/platform/webhooks/{id}. Lists show it.
  • With it off, those four are simply not queued for that endpoint (nothing in its delivery log). Every other event, including ordinary booking.created, booking.rescheduled and job.completed (silent: false), is sent as before.
  • With it on, the payloads are exactly as documented here (history, unassigned, silent included). Use those fields to decide what, if anything, to tell your users: a history: true event is about a time that has already passed.
  • The job's own timeline in CrewLink records these changes either way.

#What each office path sends

notify: false (and a job whose homeowner texts are off) and silent: true only control what reaches the homeowner; they never stop a webhook. The exceptions are the needs-attention flags a double-booking and a job not closed out add, which are written quietly with notify: false (no job.needs_attention). Every event about a job carries its techs where the event has them.

Rows marked historyEvents reach only endpoints with historyEvents: true; every other row reaches every endpoint subscribed to the event.

Path Webhook With notify: false / silent: true Needs historyEvents: true
POST /v1/office/jobs, a future start booking.created the same no
POST /v1/office/jobs, a past start written as history (allowPast, source: "import") booking.created with history: true the same historyEvents
POST /v1/office/jobs with allowQueue, queued request.created with queued: true the same no
POST /v1/office/jobs/{id}/move, a booked job booking.rescheduled (from) the same no
move from the intake queue booking.created (queued), booking.rescheduled with from: null (unscheduled) the same no
move to a past time (history) booking.created (queued) or booking.rescheduled, with history: true the same historyEvents
move with allowOverlap that skipped an overlap the move's event, plus job.needs_attention (Double-booked with #N) the move's event only; the flag is written without job.needs_attention no
not closed out (CrewLink, every 15 minutes: a booked job still not done 3 hours after its scheduled end or by 21:00 its day; once per job) job.needs_attention (Not closed out: ..., source: "system", by: "closeout") the same (webhooks aren't homeowner messages) no
move with techIds: [] (unassign) booking.rescheduled with techs: [], unassigned: true the same historyEvents
POST /v1/office/jobs/{id}/cancel booking.cancelled (techs: who was going) the same no
POST /v1/office/jobs/{id}/complete job.completed job.completed with silent: true only the silent: true one: historyEvents
POST /v1/office/jobs/{id}/history (correcting a cancelled or done import) none, deliberately; updatedAt moves, so updatedSince reads pick it up none n/a

#block.created

Time off, training or another block on a tech's calendar. Nothing new is offered inside it; jobs already booked there are listed in conflicts for the office to move.

Field Type Meaning
id integer Block id
personId integer The tech
start, end string Local wall clock, YYYY-MM-DDTHH:mm
timezone string The person's zone (start and end are on it)
reason string or null
conflicts integer[] Booking ids inside the block
externalRefs object Integrators' ids for the block, by source
source, platform, forced Who added it: see Who made a change
json
{ "id": 58260, "event": "block.created", "createdAt": "2026-10-12T15:31:00.000Z", "data": { "id": 17, "personId": 3, "start": "2026-10-14T08:00", "end": "2026-10-14T17:00", "timezone": "America/Chicago", "reason": "Training", "conflicts": [1042], "externalRefs": {}, "source": "office", "platform": null, "forced": false } }

#block.deleted

id, personId, externalRefs (the block's), blockSource (office for blocks made through the API or the app, google or outlook for calendar busy time), and source, platform, forced (who removed it).

json
{ "id": 58261, "event": "block.deleted", "createdAt": "2026-10-12T15:40:00.000Z", "data": { "id": 17, "personId": 3, "externalRefs": { "acme": "pto-9" }, "blockSource": "office", "source": "platform", "platform": "acme", "forced": false } }

#job.en_route, job.arrived, job.completed

The tech moved the job to the next stage in the field app (or, for job.completed, the office or an integration completed it). The three events share one shape:

Field Type Meaning
jobId integer
personId integer or null The tech; null when the office or an integration completed the job
at string When the tech tapped (ISO 8601, UTC). For a tap the app queued offline, when it happened, which can be earlier than createdAt. For an office completion, the at it was given
etaMinutes integer or null job.en_route only, when the tech's location was sent: estimated minutes from now to arrive (null when a queued tap is so old the tech should be there already)
minutesOnSite integer or null job.completed only: minutes from arrival to done
source string job.completed only: field_app (the tech), api (a company or platform key) or office (a signed-in office user)
silent boolean job.completed only: true when the office or an integration completed it with silent: true (the customer got no review text), else false. A silent completion still sends the event
by string or null job.completed from the office or an integration: the platform's slug when a platform completed it
json
{ "id": 58301, "event": "job.completed", "createdAt": "2026-10-13T21:12:00.000Z", "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "data": { "source": "field_app", "silent": false, "jobId": 1042, "personId": 3, "at": "2026-10-13T21:12:00.000Z", "etaMinutes": null, "minutesOnSite": 94, "externalRef": null, "externalSource": null, "externalRefs": {} } }

#job.running_late

The tech said the current job will run long. CrewLink extended it and handled every later job of the tech's day right away: kept it if it still fits, handed it to a teammate at the same time, moved it to the nearest open time, or (when none was found) texted the homeowner a choice of times. Each homeowner whose job changed was texted, and each job that changed also sends a booking.rescheduled with source running_late.

Field Type Meaning
jobId integer The job running long
personId integer The tech
extraMinutes integer How much longer
newEnd string The job's new local end time
changes[] array One entry per later job of the day
changes[].jobId integer
changes[].action string kept, handed_off, new_time or needs_call
changes[].local string or null The job's local start after the change (null for needs_call when it has no time)
changes[].techs string[] First names of the techs now on it
json
{
  "id": 58288,
  "event": "job.running_late",
  "createdAt": "2026-10-13T17:05:00.000Z",
  "data": {
    "jobId": 1040,
    "personId": 3,
    "extraMinutes": 45,
    "newEnd": "2026-10-13T13:15",
    "changes": [
      { "jobId": 1042, "action": "handed_off", "local": "2026-10-13T14:30", "techs": ["James"] },
      { "jobId": 1051, "action": "kept", "local": "2026-10-13T16:30", "techs": ["Maria"] }
    ]
  }
}

#job.risk_changed

A stop's risk level changed. CrewLink reads each subscribed company's dispatch board for today every minute and sends one event per stop (per tech, for a crew job) whose level moved since the last one sent; a stop never sent before counts as on_time. The levels, bases and reasons are those of the board's stops[].risk (platforms.md).

Field Type Meaning
jobId integer The job
personId integer The tech this is about
before, after string on_time, at_risk or late
reason string or null traffic, previous_overrun, not_started, tight_schedule or unknown; null when after is on_time
basis string live (the tech's next stop) or chained (a later one, from the ETA before it)
start string Scheduled start (UTC)
eta string or null Expected arrival (UTC)
minutesLate integer Expected arrival minus start; negative is early

Plus externalRef, externalSource, externalRefs like every job event.

json
{
  "id": 61944,
  "event": "job.risk_changed",
  "createdAt": "2026-10-12T15:30:00.000Z",
  "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
  "data": { "jobId": 1190, "personId": 2, "before": "on_time", "after": "late", "reason": "not_started", "basis": "live", "start": "2026-10-12T15:45:00.000Z", "eta": "2026-10-12T16:48:00.000Z", "minutesLate": 63, "externalRef": "appt_88214", "externalSource": "acme", "externalRefs": { "acme": "appt_88214" } }
}

#dispatch.position

A tech's position and field status, for a live map. At most one per tech per 60 seconds, and only when the tech has moved 100 m or more since the last one sent, or their status changed. Only while the tech is on the clock (30 minutes before their first working window or visit of the day to 30 minutes after their last); never off-shift. Sent after a fix from the field app or an On the way / Arrived / Done tap.

Subscribe to it by name: a * subscription doesn't get it (it is the one high-volume event). A platform's endpoints get it by the platform's position rule: a grant with peopleNames: "full" whenever the company's endpoints do; any other grant only while the tech is working a job (platforms.md).

Field Type Meaning
personId integer The tech
externalRefs object The tech's ids in other systems ({ "acme": "tech_42" })
status string idle, en_route or on_site (on the clock, so never off)
previousStatus string or null The status in the last one sent (null for the first)
currentJobId, nextJobId integer or null The job they're on, and the next one not started
position object lat, lng, accuracy (metres), heading (degrees from true north), speed (m/s), recordedAt (when the phone took it, UTC); the last three are null when the phone didn't send them
json
{
  "id": 61950,
  "event": "dispatch.position",
  "createdAt": "2026-10-12T15:29:10.000Z",
  "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
  "data": { "personId": 1, "externalRefs": { "acme": "tech_42" }, "status": "en_route", "previousStatus": "idle", "currentJobId": null, "nextJobId": 1188, "position": { "lat": 41.958, "lng": -87.688, "accuracy": 8, "heading": 90, "speed": 12, "recordedAt": "2026-10-12T15:29:00.000Z" } }
}

CrewLink keeps a tech's last known position for 30 days, and a thinned trail of on-the-clock fixes for the company's locationRetentionDays (default 90; platforms.md).

#deposit.paid

json
{ "id": 58215, "event": "deposit.paid", "createdAt": "2026-10-12T15:33:40.000Z", "data": { "jobId": 1042, "amountCents": 4900 } }

The amount is in the service's currency. The homeowner also gets a receipt text.

#invoice.paid

json
{ "id": 58330, "event": "invoice.paid", "createdAt": "2026-10-14T02:10:00.000Z", "data": { "invoiceId": 77, "jobId": 1042, "amountCents": 9572 } }

#review.created

json
{ "id": 58340, "event": "review.created", "createdAt": "2026-10-14T00:02:00.000Z", "data": { "jobId": 1042, "rating": 5 } }

Read the comment with GET /v1/office/reviews.

#request.created

Field Type Meaning
requestId integer Also the job id once booked
service string Service name
requested array { "date": "YYYY-MM-DD", "part": "morning" | "afternoon" | "any" }, best first (may be empty for a client request with no days given)
source string booking_page (a homeowner, out of area), client (a person at a client company), api (a client company's API key) or share_link (a client's sales rep through a share link, when no time fit)
client object or null { id, name }: the client company that filed it; null from the booking page
filedBy object or null { userId, name }: the person who filed it; null from the booking page or an API key
queued boolean Only on a request queued instead of refused (allowQueue): true
reason, start, local Queued requests: why it couldn't be booked, and the time asked for (requested is null; source also office, platform, api, import or crewconnect)
externalRef, externalSource, externalRefs The job's external ids, as on every job event (empty map and nulls when it has none)
shareLinkId, bookedBy string, object The client share link and the rep ({ repName, repEmail }) when it came through one; null otherwise (on every job event)
json
{ "id": 58270, "event": "request.created", "createdAt": "2026-10-12T18:20:00.000Z", "data": { "requestId": 1043, "service": "Repair visit", "requested": [ { "date": "2026-10-20", "part": "morning" }, { "date": "2026-10-21", "part": "any" } ], "source": "client", "client": { "id": "9b1e6f2a-4c3d-4e8f-a7b6-2d1c0e9f8a75", "name": "Globex Solar" }, "filedBy": { "userId": "5c2d9e1f-7a4b-4c8d-9e0f-1a2b3c4d5e6f", "name": "Dana One" } } }
json
{ "id": 58271, "event": "request.created", "createdAt": "2026-10-12T18:21:00.000Z", "data": { "requestId": 1051, "service": "Repair visit", "queued": true, "reason": "That time was taken when the customer asked for it.", "source": "booking_page", "start": "2026-10-13T19:30:00.000Z", "local": "2026-10-13T14:30", "requested": null } }

When the office places a queued request, booking.created follows with the same id (bookingId).

#trip.booked

The office booked a trip: one tech travels to serve waiting out-of-area requests. Every stop is now booked and each homeowner was texted their time.

Field Type Meaning
tripId string The trip's id (a UUID)
personId integer The tech travelling
firstDay, lastDay string Local dates, YYYY-MM-DD
jobs integer[] The requests booked on the trip
json
{ "id": 58400, "event": "trip.booked", "createdAt": "2026-10-15T14:00:00.000Z", "data": { "tripId": "9a3d7c1e-4b2f-4e8a-b6c5-0d1e2f3a4b5c", "personId": 6, "firstDay": "2026-10-20", "lastDay": "2026-10-21", "jobs": [1043, 1047, 1049] } }

Each stop also sends its own booking.created with source trip and the tripId. If any in-area jobs were handed to teammates to free the trip days, each comes as a booking.rescheduled with source trip_handoff.

#day.reviewed

A day's plan was compared with what happened in the field: every night from 3:00 AM company time for the day before (once per day), and whenever the office asks for a review (POST /v1/office/brain/review). The numbers are the same as Plan vs actual; fetch GET /v1/office/brain/day for the stop-by-stop detail. A day can be reviewed more than once (on request during the day, then at night): keep the latest by computedAt, and treat complete: true as final.

Field Type Meaning
date string The local day reviewed, YYYY-MM-DD
computedAt string When the review was made (ISO instant)
complete boolean true when the day was over at computedAt
onTimeRate number or null Percent of arrivals no more than 15 minutes after the planned start
etaMaeOurs, etaMaeProvider number or null CrewLink's and the traffic provider's average arrival error, minutes
paired object { n, ours, provider }: both errors on the same drives
driveErrMean, onsiteErrMean number or null Real minus planned drive minutes and minutes on site, on average
counts object plannedStops, arrivals, done, drives, drivesMeasured, providerLookups, paired, checkins, replans, customersMoved, snapshots
json
{
  "id": 58512,
  "event": "day.reviewed",
  "createdAt": "2026-10-13T08:10:00.000Z",
  "data": {
    "date": "2026-10-12",
    "computedAt": "2026-10-13T08:10:00.000Z",
    "complete": true,
    "onTimeRate": 92.3,
    "etaMaeOurs": 6.1,
    "etaMaeProvider": 8.4,
    "paired": { "n": 21, "ours": 6.0, "provider": 8.4 },
    "driveErrMean": 2.2,
    "onsiteErrMean": 4.5,
    "counts": { "plannedStops": 26, "arrivals": 26, "done": 25, "drives": 24, "drivesMeasured": 23, "providerLookups": 21, "paired": 21, "checkins": 9, "replans": 5, "customersMoved": 2, "snapshots": 6 }
  }
}

#badge.earned

A tech reached a new tier of a badge (Wins): on the arrived or done tap, or the review, that got them there, or in the nightly recompute. Each person, badge and tier is sent once. Tiers reached from history before Wins existed are not sent.

Field Type Meaning
personId number The tech
badge string clockwork, five_star, early_bird, closer, one_and_done or heads_up
badgeName string The badge's name, e.g. Clockwork
tier string bronze, silver or gold; Clockwork also platinum and diamond
threshold number What the tier needs
value number The tech's number when it was earned
earnedAt string ISO instant
json
{
  "id": 58600,
  "event": "badge.earned",
  "createdAt": "2026-10-12T16:52:00.000Z",
  "data": { "personId": 12, "badge": "clockwork", "badgeName": "Clockwork", "tier": "silver", "threshold": 25, "value": 25, "earnedAt": "2026-10-12T16:52:00.000Z" }
}

#connection.requested

Sent to the company being asked.

json
{ "id": 58500, "event": "connection.requested", "createdAt": "2026-10-12T19:00:00.000Z", "data": { "connectionId": "0b6e4a1c-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "from": "Lakeside Comfort" } }

#connection.accepted

Sent to the company that asked.

json
{ "id": 58501, "event": "connection.accepted", "createdAt": "2026-10-12T19:20:00.000Z", "data": { "connectionId": "0b6e4a1c-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "partner": "Northline Heating & Air" } }

#connection.ended

Sent to the other side.

json
{ "id": 58502, "event": "connection.ended", "createdAt": "2026-11-02T09:00:00.000Z", "data": { "connectionId": "0b6e4a1c-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "by": "Lakeside Comfort" } }

See CrewConnect for what each side can see and do.

#message.posted

A person posted a message in Messaging: the company channel, a job thread, a tech's office line, or a CrewConnect partner channel. Direct messages are never sent, and neither are the quiet system messages CrewLink writes into job threads (those mirror the booking and job events above). A job thread or partner channel shared with a CrewConnect partner sends the event to both companies; each sees the sender's name as their app shows it (sender.userId is null and the name reads "First (Company)" for someone at the other company, or "<Company> tech" for a partner's tech when the partner shares open times only).

Field Type Meaning
messageId number The message
conversationId string The conversation (GET /v1/messaging/conversations/{id} with a person's token)
conversationKind string master, job, office or partner
jobId number or null For a job thread
sender object { userId, name }
body string The text, up to 4,000 characters
attachments number How many files came with it
createdAt string When it was posted
json
{ "id": 58530, "event": "message.posted", "createdAt": "2026-10-12T15:42:00.000Z", "data": { "messageId": 812, "conversationId": "7b1d0c52-0e44-4b61-9a3e-5d2f1c9e8a10", "conversationKind": "job", "jobId": 1042, "sender": { "userId": "3f6a9e1c-8d2b-4c7e-a1f0-9b8c7d6e5f40", "name": "Dana Ruiz" }, "body": "Side gate code is 4411", "attachments": 0, "createdAt": "2026-10-12T15:42:00.000Z" } }

#job.needs_attention

Someone flagged the job with POST /v1/office/jobs/{id}/attention, or CrewLink did: a tech's office-review check-in, a customer text that failed, a recurring visit out of the service area, or a job not closed out (still not marked done 3 hours after its scheduled end, or by 21:00 its day: reason Not closed out: still <stage> since <time>, source: "system", by: "closeout", once per job; see office.md). Clearing the flag sends nothing.

Field Type Meaning
jobId integer
reason string What needs attention
at string When it was flagged (ISO 8601, UTC)
source string api (a company or platform key), office (a signed-in office user), tech (an office-review check-in) or system (CrewLink)
by string or null The platform's slug when a platform flagged it; for system: closeout (not closed out), recurring (out of the service area), else null
json
{ "id": 58350, "event": "job.needs_attention", "createdAt": "2026-11-02T18:04:00.000Z", "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "data": { "jobId": 1042, "reason": "Roof access unclear", "at": "2026-11-02T18:04:00.000Z", "source": "api", "by": "acme", "externalRef": "appt_88213", "externalSource": "acme", "externalRefs": { "acme": "appt_88213" } } }

#attachment.uploaded

A file was added to a job (once per file: after a confirmed upload URL, or a direct upload).

json
{ "id": 58351, "event": "attachment.uploaded", "createdAt": "2026-11-02T18:05:00.000Z", "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "data": { "jobId": 1042, "attachment": { "id": "c0f7a1d2-5b3e-4f6a-8c9d-0e1f2a3b4c5d", "kind": "proposal", "name": "Proposal - Quinn.pdf", "contentType": "application/pdf", "bytes": 482113 }, "uploadedBy": { "kind": "platform", "id": "6d0c1f9a-3b2e-4c55-8f71-0a9b8c7d6e5f", "platform": "acme" }, "at": "2026-11-02T18:05:00.000Z", "externalRef": "appt_88213", "externalSource": "acme", "externalRefs": { "acme": "appt_88213" } } }

uploadedBy.kind is platform, user or company (the company API key). Fetch the file (and a fresh download link) with GET /v1/office/jobs/{id}/attachments.

#person.updated, availability.updated, person.base.updated

About the techs on the team, so an integration can mirror them. Techs appear as first name and last initial (displayName).

Event When data
person.updated A person was added or changed (POST/PATCH /v1/office/team, or new weekly hours) personId, displayName, active, kind, branchId, timezone, home (lat, lng), base (the base in force today, or null), availability (every window per day, [] off), hours (each day's envelope or null), maxTravelMiles, externalRefs, source, platform, forced
availability.updated Their weekly hours changed personId, externalRefs, availability (Monday first, each day a list of [start, end] minutes, [] off: the shape PUT /v1/office/team/{id}/availability takes), hours (each day's envelope, [start, end] or null), timezone, source, platform, forced
person.base.updated A base was added or removed (a booked trip adds one too) personId, action (created or deleted), base (id, from, to, lat, lng, address, addressParts, reason, externalRefs, tripId), externalRefs (the person's), timezone, source, platform, forced
json
{ "id": 58360, "event": "availability.updated", "createdAt": "2026-11-02T18:10:00.000Z", "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b", "data": { "personId": 57, "externalRefs": { "acme": "tech_42" }, "availability": [[[480, 720], [780, 1020]], [[480, 1020]], [[480, 1020]], [[480, 1020]], [[480, 960]], [], []], "hours": [[480, 1020], [480, 1020], [480, 1020], [480, 1020], [480, 960], null, null], "timezone": "America/Chicago", "source": "platform", "platform": "acme", "forced": false } }

#Who made a change: source, platform, forced

block.created, block.deleted, person.updated, availability.updated and person.base.updated say who made the change, so an integration that writes these itself can skip its own echoes:

Field Values
source platform (a platform key), office (a signed-in office user), api (the company API key), tech (the field app), admin (CrewLink staff acting as the owner), system (CrewLink itself: a CLI step, a default week)
platform The platform's slug when source is platform (e.g. "acme"), else null
forced true when an owner overrode the integration that owns the team's availability (force: true; office.md)

An integration ignores events with source: "platform" and its own slug in platform, and applies the rest (platforms.md). Events about the same person can arrive out of order: read the person (GET /v1/office/team/{id}) rather than applying an older event over a newer one.

#platform.grant.created, platform.grant.revoked

Only to a platform's platform-wide subscription: a company granted the platform access (or revoked it). data: platform (slug), company (id, slug, name, timezone), scopes, grantedAt, revokedAt (null on created). After a revoke the subscription gets nothing more about that company. Example in platforms.md.

#site.created, site.updated, site.merged

About a site: one property for one client company (or the company's own work), with every visit ever made there. They go out in the same transaction as the visit event that caused them, just before it: a first booking at a property sends site.created then booking.created; finishing the last visit to do sends site.updated (status: "idle") then job.completed; flagging a visit for attention at an idle site sends site.updated (active) then job.needs_attention. An endpoint subscribed to * gets them too (new events always join *): ignore what you don't use. An opt-in emission's site events (a history write: historyEvents) are opt-in the same way.

Event When
site.created Its first visit was booked or filed (also after a split to a new site)
site.updated Its status changed (previousStatus says from what), or it was merged into, split, or its ids changed (PATCH)
site.merged Another site was merged into this one (after this one's site.updated), by the office, an integration, or because the company's own site at an address folded into the client's

Every one has the same data, the site as it is after the change. No customer or tech names.

Field Type Meaning
siteId integer The site
status string active (a visit to do or needing attention), idle or merged
clientCompanyId string or null The client company it is for; null for the company's own work
mergedIntoId integer or null Set when this site was merged into another
externalRefs object The site's ids by source (its most recent visit's), e.g. { "acme": "job_7731" }
firstFiledAt string When its first visit was filed
jobIds integer[] Its visits (job ids), oldest filed first
visits object[] The same, each { jobId, visit, service, serviceId, revisit, status, stage }: visit is its place among all of them, cancelled ones included (job reads' siteVisit leaves cancelled ones out); status and stage as on job reads
counts object { visits, active, done, cancelled }
lastVisit object or null { jobId, at, service }: the most recent visit done
nextVisit object or null { jobId, start, service }: the next scheduled visit not yet done
previousStatus string site.updated only
mergedSiteId, movedJobIds, mergedSite site.merged (and its site.updated): the site absorbed, the visits that moved, and { siteId, status: "merged", mergedIntoId, externalRefs } of the absorbed site
splitJobId, toSiteId, fromSiteId After a split
json
{
  "id": 61022,
  "event": "site.updated",
  "createdAt": "2026-10-14T17:02:00.000Z",
  "companyId": "8a1f0c3e-2b4d-4e6f-9a8b-7c6d5e4f3a2b",
  "data": {
    "siteId": 482,
    "status": "idle",
    "previousStatus": "active",
    "clientCompanyId": "57f617f7-5592-4ba9-a012-6efaaba60677",
    "mergedIntoId": null,
    "externalRefs": { "acme": "job_7731" },
    "firstFiledAt": "2026-10-01T15:02:11.000Z",
    "jobIds": [9104, 9120, 9188],
    "visits": [
      { "jobId": 9104, "visit": 1, "service": "Residential Solar Site Survey", "serviceId": 1, "revisit": false, "status": "cancelled", "stage": "scheduled" },
      { "jobId": 9120, "visit": 2, "service": "Residential Solar Site Survey", "serviceId": 1, "revisit": false, "status": "booked", "stage": "done" },
      { "jobId": 9188, "visit": 3, "service": "Residential Survey Revisit", "serviceId": 3, "revisit": true, "status": "booked", "stage": "done" }
    ],
    "counts": { "visits": 3, "active": 0, "done": 2, "cancelled": 1 },
    "lastVisit": { "jobId": 9188, "at": "2026-10-14T17:02:00.000Z", "service": "Residential Survey Revisit" },
    "nextVisit": null
  }
}

The backfill that places visits booked before sites existed (and regroups after a rule change) sends none of these.

#external_message.created, .updated, .deleted

Only to the endpoints of the platform that owns a job's mirrored conversation (its platform-wide subscription or a company-level endpoint it made), while the company's grant includes webhooks; never to the company's own endpoints or another platform's, even with *. Sent when an office user (or the company API key) answers in the job's Conversations tab, and when the author edits (.updated) or deletes (.deleted) that answer. Only to endpoints whose events list names the event or * (a PATCH of events applies to the next one). data: id (CrewLink's message id), source (crewlink_office), channel (client, tech or internal), jobId, siteId, clientCompanyId, externalRef, externalSource, externalRefs, caseRef (the platform's job or case id), threadKey (null), author (userId, personId, displayName as the grant's people names say, role), body (Markdown; empty on .deleted), attachments (empty), createdAt, editedAt, deletedAt. Messages the platform mirrors in never produce them. Example in platforms.md.

#Testing locally

CrewLink only delivers to public https hosts, so it can't reach localhost directly. Two ways to test:

Tunnel to your machine. Run your handler locally and expose it with a tunnel that gives you a public https host name (for example cloudflared tunnel --url http://localhost:3000 or ngrok http 3000). Subscribe that URL in your sandbox company, then make something happen: book through the booking page, or add a block with POST /v1/office/blocks (a quick way to get block.created). Delete the endpoint when you're done.

Sign a request yourself. To test your verification code without CrewLink, build a signed request the same way CrewLink does:

bash
SECRET='whsec_test_secret'
BODY='{"id":1,"event":"booking.created","createdAt":"2026-10-12T15:30:00.000Z","data":{"bookingId":1042,"source":"booking_page","serviceId":2,"start":"2026-10-13T19:30:00.000Z","local":"2026-10-13T14:30","techs":[3]}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/crewlink \
  -H 'content-type: application/json' \
  -H 'crewlink-event: booking.created' -H 'crewlink-delivery: 1' \
  -H "crewlink-signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"

Change one character of BODY after signing and your handler should refuse it.

CrewLink API · Reference · Changelog