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
- What a delivery looks like
- Verifying signatures (Node, Python, PHP)
- Retries
- Ordering and duplicates
- Events
- Testing locally
#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 |
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
{
"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:
httpsonly, 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,*.corpand 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.
{
"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 |
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.
{ "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 |
curl -s "$CREWLINK/v1/office/webhooks/6f1c2a7e-3b9d-4c51-9a0e-2d8f4b7c1e93/deliveries?jobId=1042" -H "Authorization: Bearer $KEY"{
"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:
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...tis the Unix time (seconds) when this attempt was sent. It changes on each retry.v1is the hex HMAC-SHA256 of the string"<t>.<raw body>", keyed with the whole secret (including thewhsec_prefix).
To verify:
- 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.
- Split the header on
,and each part on the first=, and taketandv1. - Compute HMAC-SHA256 of
t + "." + rawBodywith your secret, as lowercase hex. - Compare it with
v1in constant time. - Reject the delivery if
tis 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
// 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
# 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
// 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.rescheduledcan arrive before thebooking.createdit follows if the first attempt ofbooking.createdfailed. UsecreatedAtto 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.
{
"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:
{
"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 |
{
"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 |
{
"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 |
historyEventsisfalseby default: for every endpoint that existed before it was added, and for new ones unless you sendhistoryEvents: truewhen you subscribe (orPOST /v1/platform/webhooks). Turn it on or off withPATCH /v1/office/webhooks/{id}orPATCH /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.rescheduledandjob.completed(silent: false), is sent as before. - With it on, the payloads are exactly as documented here (
history,unassigned,silentincluded). Use those fields to decide what, if anything, to tell your users: ahistory: trueevent 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 |
{ "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).
{ "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 |
{ "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 |
{
"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.
{
"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 |
{
"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
{ "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
{ "id": 58330, "event": "invoice.paid", "createdAt": "2026-10-14T02:10:00.000Z", "data": { "invoiceId": 77, "jobId": 1042, "amountCents": 9572 } }#review.created
{ "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) |
{ "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" } } }{ "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 |
{ "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 |
{
"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 |
{
"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.
{ "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.
{ "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.
{ "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 |
{ "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 |
{ "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).
{ "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 |
{ "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 |
{
"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:
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.