CrewLink API

Booking page

Everything a homeowner does online: see a company's services, find open times at their address, hold one, book it (with a deposit when the service takes one), ask for service when they live outside the area, and later view, move or cancel the booking, pick a new time when the company had to move it, pay the invoice and leave a review.

None of these endpoints need an API key. A company is addressed by its slug (northline in /book/northline); a homeowner's own booking, invoice or review is authorized by the token in the link CrewLink texted them. A company's own website can call them straight from the browser once its site is on the company's allowed origins, optionally with the public integration key (X-CrewLink-Key) for its own limits: see Embed on your website.

#The flow at a glance

sequenceDiagram
  participant H as Homeowner
  participant S as Your site
  participant C as CrewLink API
  S->>C: GET /v1/public/{slug}
  C-->>S: company, timezone, services
  H->>S: types an address
  S->>C: GET /v1/public/geocode?q=...&slug=...
  C-->>S: results (label, lat, lng)
  S->>C: POST /v1/public/{slug}/availability
  C-->>S: slots[] (or outsideArea: true)
  H->>S: picks a time
  S->>C: POST /v1/public/{slug}/holds
  C-->>S: holdId, holdSecret, expiresAt (5 min)
  H->>S: name, phone, email
  S->>C: POST /v1/public/{slug}/bookings
  C-->>S: bookingId, techs, manageToken, deposit
  C-->>H: text: confirmation with the manage link

Times offered are real: each one is a start that a qualified tech can make, counting their other jobs, their calendar, time off and the drive at that hour. A hold rechecks the time against the live schedule and reserves the tech(s) for 5 minutes, so two people can never book the same tech for the same time.

#Embed on your website

#Option 1: the hosted booking page

Every company has a hosted page:

https://go.crewlink.ai/book/{slug}

It runs the whole flow above (including out-of-area requests and deposits), in the company's name. Link to it from a "Book online" button, or embed it:

html
<iframe src="https://go.crewlink.ai/book/northline"
        title="Book online" style="width:100%;min-height:760px;border:0"></iframe>

The texts CrewLink sends link the homeowner to the matching hosted pages:

Page Link What the homeowner does
Booking /book/{slug} Books a time, or asks for one when outside the area
My booking /b/{bookingId}?t={manageToken} Sees the booking, moves or cancels it
New time /o/{jobId}?t={token} Picks one of the times offered after the company had to move the booking
Invoice /i/{invoiceId}?t={token} Sees and pays the invoice
Review /r/{jobId}?t={token} Rates the visit

#Option 2: build it into your site

Build your own screens on the endpoints below and call them from the visitor's browser. Your web developer needs two things from the owner's Settings > Online booking (or the API):

  1. Your site on the allowed origins. Add every https origin the booking screens run on (https://www.yourcompany.com, and https://yourcompany.com if pages are served there too) with PATCH /v1/office/settings allowedOrigins. Browsers only give CrewLink's answers to pages on that list (CORS rules). For local development, use an https tunnel or a staging site that's on the list.
  2. The integration key (recommended). Make it with POST /v1/office/integration-key/rotate and send it as X-CrewLink-Key on every call. It is public, made for page code: it grants no data access, it only counts holds, bookings, requests and address search per key (300 a minute, 50 live holds) instead of per visitor IP. Never put the company API key in a page; it acts as the owner.
bash
# Once, by the owner (server side, with the company API key)
curl -X PATCH $CREWLINK/v1/office/settings -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"allowedOrigins": ["https://www.northline.example", "https://northline.example"]}'
curl -X POST $CREWLINK/v1/office/integration-key/rotate -H "authorization: Bearer $KEY"
# {"integrationKey":"pk_<integration key>"}  shown once: put it in your page code

The call sequence, in your page's script:

js
const API = 'https://go.crewlink.ai';
const SLUG = 'northline';
const headers = { 'content-type': 'application/json', 'x-crewlink-key': 'pk_<integration key>' };
const call = async (method, path, body) => {
  const r = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
  const data = await r.json();
  if (!r.ok) throw Object.assign(new Error(data.message), { status: r.status, code: data.error, retryAfter: r.headers.get('retry-after') });
  return data;
};

// 1. The company and its services, once.
const { company, services } = await call('GET', `/v1/public/${SLUG}`);
// 2. Coordinates for the address (search when the visitor pauses typing).
const { results } = await call('GET', `/v1/public/geocode?q=${encodeURIComponent(typed)}&slug=${SLUG}`);
const { lat, lng } = results[0];
// 3. Open times. Show `local`, keep `start`.
const { slots, outsideArea } = await call('POST', `/v1/public/${SLUG}/availability`, { serviceId, lat, lng, days: 14 });
// outsideArea: show no times, offer POST /v1/public/{slug}/requests instead.
// 4. Hold the chosen time (send `start` back unchanged). 409 slot_taken / too_soon: refresh step 3.
const hold = await call('POST', `/v1/public/${SLUG}/holds`, { serviceId, lat, lng, start: slot.start });
// 5. Book within 5 minutes with the holdSecret. 410 hold_expired: hold again.
const booking = await call('POST', `/v1/public/${SLUG}/bookings`, {
  holdId: hold.holdId, holdSecret: hold.holdSecret, customer: { name, phone, email }, address,
});
// 6. booking.deposit: pay it with the provider's client-side SDK (see Deposits).
// 7. Show booking.local and booking.techs. The homeowner also gets a text with their manage link.

Step by step:

  1. Load the company once with GET /v1/public/{slug}. Keep the services and units (show distances in it).
  2. Get coordinates for the address. Use GET /v1/public/geocode (send slug) or your own geocoder. Availability needs lat and lng; the street address is stored separately at booking.
  3. Ask for open times with POST /v1/public/{slug}/availability. If outsideArea is true, show no times and offer the request form instead (nearestKm says how far away the nearest branch is). Show local to the homeowner; keep start to send back. local is the wall clock where the job is (timezone); name the zone if the visitor may be elsewhere.
  4. Hold the chosen time with POST /v1/public/{slug}/holds, sending the slot's start unchanged. On 409 slot_taken or too_soon, refresh availability and let them pick again. Keep holdId and holdSecret in the page's memory only (not in the URL or analytics).
  5. Collect contact details and book with POST /v1/public/{slug}/bookings within 5 minutes (expiresAt). On 410 hold_expired, go back to step 4 with the same time.
  6. Take the deposit if deposit is not null (see deposits).
  7. Show the confirmation with the local time and the tech first names. The homeowner also gets a text with their manage link (say so only when texted is true). If you show your own "manage" page, keep bookingId and manageToken with the homeowner (for example in your own link to them); CrewLink shows the token only once. The manage-link endpoints (/v1/bookings/{id}) can be called from any page.

On 429, wait Retry-After seconds (pages can read it) before trying again; too_many_holds means the key (or, without a key, the visitor's IP) has too many live holds.

The same calls work from your server (same sequence, same headers). With the integration key, your server's calls count per key rather than as one IP.

Your own systems learn about every booking, move and cancellation through webhooks, whichever front end the homeowner used.


#GET /v1/public/{slug}

The company and its bookable services, in the order the company added them.

Path Type Notes
slug string The company's booking slug
bash
curl $CREWLINK/v1/public/northline
json
{
  "company": { "name": "Northline Heating & Air", "slug": "northline", "timezone": "America/Chicago", "country": "US", "units": "mi" },
  "services": [
    { "id": 1, "name": "Seasonal tune-up", "minutes": 60, "peopleNeeded": 1, "days": 1, "priceCents": 12900, "depositCents": 0, "currency": "USD", "category": "residential" },
    { "id": 2, "name": "Repair visit", "minutes": 90, "peopleNeeded": 1, "days": 1, "priceCents": 8900, "depositCents": 4900, "currency": "USD", "category": "residential" },
    { "id": 3, "name": "New system install", "minutes": 150, "peopleNeeded": 2, "days": 1, "priceCents": null, "depositCents": 0, "currency": "USD", "category": null }
  ],
  "texts": true
}
Field Notes
category residential, commercial, other, or null when the company hasn't set it (set on services)
company.timezone The company's default zone. A branch in another zone has its own: availability, holds and bookings each say which zone their times are in
company.country ISO 3166-1 alpha-2 of the head office
company.units mi or km: how to show distances to this company's customers
minutes How long the work usually takes. The time CrewLink reserves can be longer (it learns each tech's real times)
peopleNeeded Techs who go together
days Consecutive days the job takes (same start time each day)
priceCents List price, or null when it's quoted on site
depositCents Taken at booking when above 0
texts true when customer texts really go out on this server (an SMS provider is set up). When false (a test server), don't tell the homeowner "we texted you"

Errors: 404 not_found (no company with that slug).

#GET /v1/public/geocode

Address search for the booking form. With slug, limited to the company's countries (its own and every branch's: a US company with a London branch searches the US and the UK) and biased to its area; without, anywhere. Answered by Mapbox when CrewLink has it set up, otherwise (or while Mapbox is down) by OpenStreetMap; source says which.

Query Type Required Notes
q string yes 4 to 200 characters
slug string no Limit results to this company's countries and bias them to its area
country string no One more country to search (ISO 3166-1 alpha-2), e.g. while adding a branch abroad
bash
curl "$CREWLINK/v1/public/geocode?q=2400%20N%20Western%20Ave%20Chicago&slug=northline"
json
{
  "results": [
    { "label": "2400 North Western Avenue, Chicago, Illinois 60647, United States", "lat": 41.9246, "lng": -87.6874, "precision": "rooftop", "country": "US", "lines": ["2400 North Western Avenue", "Chicago, IL 60647"] }
  ],
  "source": "mapbox"
}
Field Notes
results[].label The address to show. Mapbox and OpenStreetMap format it differently; don't parse it. When the geocoder dropped the house number or the street's direction you typed but found that street (9446 S Raymond Ave answered as Raymond Ave, Oak Lawn, 6530 W 79th St as 6530 79th St), they are put back: 9446 S Raymond Ave, Oak Lawn, ...
results[].lat, lng Pass these to availability
results[].precision How exact the match is: rooftop (the building), address (that house number, placed along its street), street (only the street: that number wasn't found, or a different one was) or locality (an area, town or postcode). street and locality are approximate: show "Pick an exact address", and book one only when the person confirms it (send geoPrecision and allowApproximate: true with the booking)
results[].country ISO 3166-1 alpha-2, when the geocoder said
results[].lines The address in its country's order, one line each, when the geocoder gave its parts: ["Hauptstraße 5", "10115 Berlin"], ["10 Downing St", "London", "SW1A 2AA"], ["1600 W Belmont Ave", "Chicago, IL 60657"]. The country is added as a last line when it isn't the company's
source mapbox or osm (OpenStreetMap). Absent only when no geocoder answered; results is then empty, so let the person try again

Up to 5 results. Answers are cached for a day (an OpenStreetMap answer given while Mapbox is down, for 10 minutes; an answer from neither, not at all). Each IP gets one new (uncached) lookup a second and 30 a minute (300 a minute per integration key when you send one; the one-a-second rule stays per visitor IP): search when the person pauses typing, not on every keystroke.

Errors: 422 invalid_request (q too short or long), 429 slow_down (more than one new lookup a second) or rate_limited.

#POST /v1/public/{slug}/availability

Every start time a qualified tech can make at this address, on a 15-minute grid, from the requested date for up to 28 days. Nothing sooner than an hour from now.

Body Type Required Notes
serviceId integer yes From the service list
lat number yes -90 to 90
lng number yes -180 to 180
from string no First local date, YYYY-MM-DD, in the zone of the branch serving the address. Default today there
days integer no 1 to 28, default 14
bash
curl -X POST $CREWLINK/v1/public/northline/availability \
  -H 'content-type: application/json' \
  -d '{"serviceId": 2, "lat": 41.955, "lng": -87.675, "from": "2026-10-13", "days": 3}'
json
{
  "timezone": "America/Chicago",
  "slots": [
    { "start": "2026-10-13T13:00:00.000Z", "local": "2026-10-13T08:00" },
    { "start": "2026-10-13T13:15:00.000Z", "local": "2026-10-13T08:15" },
    { "start": "2026-10-13T19:30:00.000Z", "local": "2026-10-13T14:30" }
  ],
  "outsideArea": false,
  "nearestKm": null
}
Field Notes
timezone The zone of local: the zone of the branch serving the address (a Denver address of a Chicago company gets America/Denver)
slots[].start The instant (UTC). Send it back unchanged to hold
slots[].local Wall clock where the job is, for display
outsideArea true when the address is outside every tech's service area on every day asked about (a day's base or trip area counts for its days). There are no times to offer; use POST /v1/public/{slug}/requests
nearestKm With outsideArea: straight-line km to the nearest branch (show it in the company's units: "about 63 mi from our nearest branch"). Otherwise null

An empty slots with outsideArea: false means the area is served but nothing is free in that range: try a later from.

Errors: 404 not_found (no company, or no active service with that id), 422 invalid_request.

#POST /v1/public/{slug}/holds

Holds a start time for 5 minutes while the homeowner finishes. CrewLink rechecks the time against the live schedule (not the availability answer you got earlier), picks the best tech(s) for it, and reserves them.

Body Type Required Notes
serviceId integer yes
lat number yes The same address you searched
lng number yes
start string yes ISO 8601 with offset, normally a slot's start
allowQueue boolean no Default false. true: when the time is taken or too soon, send the request to the office instead of answering 409 (see below). Needs customer
customer object with allowQueue name (1 to 200), phone (7 to 40), email (optional): who the office texts a time to
address, notes string no With allowQueue: up to 300 and 2000 characters
bash
curl -X POST $CREWLINK/v1/public/northline/holds \
  -H 'content-type: application/json' \
  -d '{"serviceId": 2, "lat": 41.955, "lng": -87.675, "start": "2026-10-13T19:30:00.000Z"}'

201

json
{
  "holdId": 1042,
  "holdSecret": "q3V9cKx0e1mB8n2ZtR7pLw4sYh6uJd5A",
  "start": "2026-10-13T19:30:00.000Z",
  "local": "2026-10-13T14:30",
  "timezone": "America/Chicago",
  "expiresAt": "2026-10-13T15:35:00.000Z"
}

holdSecret proves you made the hold: only a request carrying it can book it. Keep it with the hold on your server and never show it.

202 (only with allowQueue: true, when the time can't be held): the request goes to the office's intake queue. Nothing is held or booked; the homeowner is texted that the office will text them a time, and the office places it.

json
{
  "jobId": 1051,
  "status": "unscheduled",
  "queued": true,
  "requested": { "start": "2026-10-13T19:30:00.000Z", "local": "2026-10-13T14:30" },
  "reason": "That time was taken when the customer asked for it."
}

Show the homeowner something like "That time just went. We've sent your request to the office and they'll text you a time." Don't call bookings for it.

Status error When
404 not_found No company, or no active service with that id
409 slot_taken Someone else just took it, or no qualified tech can make it any more. Refresh availability. Never with allowQueue: true
409 too_soon Less than an hour from now. Never with allowQueue: true
409 outside_daylight The service is daylight only (daylightOnly: true in the catalog) and the visit wouldn't be between sunrise and sunset at that address on that day. The message says the day's sunrise and sunset and the latest start. Availability never offers such a time
422 invalid_request Missing or malformed field, or allowQueue without customer
429 too_many_holds This IP already has 3 live holds (with an integration key: the key has 50). Book or wait for one to expire
429 rate_limited More than 30 hold requests a minute from this IP (300 per integration key)

There's no call to release a hold: an abandoned hold expires on its own after 5 minutes and the time opens up again.

#POST /v1/public/{slug}/bookings

Turns a hold into a booking. The homeowner gets a confirmation text with their manage link now and a reminder 24 hours before.

Body Type Required Notes
holdId integer yes From the hold
holdSecret string yes From the hold, 1 to 200 characters
customer.name string yes 1 to 200 characters
customer.phone string yes 7 to 40 characters. Texts go to mobile numbers in the company's countries (its own and its branches'); a number without + and a country code is read the way the job's country writes it ((312) 555-0188 in the US, 020 7946 0958 in the UK)
customer.email string no A valid email
address string no Street address for the tech, up to 300 characters
geoPrecision string no The precision of the address match the location came from: rooftop, address, street or locality. Stored with the job. street and locality are refused (422 approximate_address) unless allowApproximate is true. Left out: not checked
allowApproximate boolean no true: the customer confirmed an approximate place ("Use this location anyway"). Default false
notes string no Gate codes, symptoms, pets; up to 2000 characters
externalRefs object no Your own ids for the booking, { "<source>": "<id>" } (up to 10). Each is unique in the company: one already on a job answers 409 duplicate_external_ref with jobId. A source that is a platform the company works with is refused (422 reserved_source)
externalRef, externalSource string no Shorthand for one entry; the source defaults to booking_page
bash
curl -X POST $CREWLINK/v1/public/northline/bookings \
  -H 'content-type: application/json' \
  -d '{
    "holdId": 1042,
    "holdSecret": "q3V9cKx0e1mB8n2ZtR7pLw4sYh6uJd5A",
    "customer": { "name": "Jordan Avery", "phone": "312 555 0142", "email": "jordan@example.com" },
    "address": "2400 N Western Ave, Chicago, IL 60647",
    "geoPrecision": "rooftop",
    "notes": "Furnace makes a rattling noise. Side door."
  }'

201

json
{
  "bookingId": 1042,
  "status": "booked",
  "start": "2026-10-13T19:30:00.000Z",
  "local": "2026-10-13T14:30",
  "timezone": "America/Chicago",
  "techs": ["Maria"],
  "manageToken": "c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl",
  "deposit": {
    "amountCents": 4900,
    "currency": "USD",
    "provider": "stripe",
    "clientSecret": "pi_3Q..._secret_...",
    "publishableKey": "pk_test_51...",
    "testPay": false
  },
  "texted": true
}
Field Notes
bookingId Same number as the hold id
techs First names only
manageToken Shown once. With bookingId it authorizes the manage link
deposit null when the service takes no deposit, or when the payment provider couldn't be reached (the booking still stands and the office collects the deposit on the invoice). See deposits
deposit.publishableKey With provider: "stripe": the Stripe publishable key to load Stripe.js with. null when the server has none: don't offer card payment, say the office will take the deposit
deposit.testPay true only on development and test servers (provider: "fake", test payments on): POST /v1/bookings/{id}/deposit/test-pay marks it paid
texted true when a confirmation text went out (an SMS provider is set up and the number is one the company texts). Only then tell the homeowner "we texted you"
Status error When
404 not_found No company, no hold with that id for this company, the hold was already booked, or holdSecret is wrong
410 hold_expired The 5 minutes ran out. Hold the time again
422 invalid_request Missing holdSecret, bad phone or email, field too long
422 approximate_address geoPrecision is street or locality (no house found) and allowApproximate isn't true. The hold stays: ask the customer to pick an exact address, or to confirm the place, and send again
429 rate_limited More than 30 booking requests a minute from this IP (300 per integration key)

#Deposits

Book, then pay: the booking stands as soon as it's made, and the deposit is a payment waiting to be confirmed. The hosted page asks for it right on the confirmation ("Pay the $49 deposit"), and the manage link shows the same step until it's paid. The confirmation text says the deposit is due and links to the manage page to pay it. An unpaid deposit doesn't cancel the booking.

With provider: "stripe" and a publishableKey, take it in the browser with Stripe.js and the Payment Element:

js
const stripe = Stripe(booking.deposit.publishableKey); // <script src="https://js.stripe.com/v3"></script>
const elements = stripe.elements({ clientSecret: booking.deposit.clientSecret });
elements.create('payment').mount('#deposit');
// On "Pay":
const { error } = await stripe.confirmPayment({ elements, redirect: 'if_required', confirmParams: { return_url: manageUrl } });
// No error: ask CrewLink, which checks with Stripe (GET /v1/bookings/{id}/deposit).

When the payment goes through, the homeowner gets a receipt text and you get a deposit.paid webhook (once, whether the Stripe webhook or the status check saw it first). With publishableKey: null, or deposit: null for a service that takes one, the office takes the deposit.

#Test-mode deposits

On development and test servers (provider: "fake", testPay: true) no money moves. POST /v1/bookings/{id}/deposit/test-pay with { "token": "<manageToken>" } marks the deposit paid (the receipt text and deposit.paid happen as for a real payment) and answers with the deposit. The hosted page shows it as a clearly-labelled "Pay deposit (test mode)" button. It doesn't exist on a production server: 404. 409 nothing_due when there's no unpaid test deposit on the booking.

#POST /v1/public/{slug}/requests

When availability says outsideArea: true, offer this instead of times. The homeowner picks up to 3 days (morning, afternoon or any time) and gets a text that the office will confirm a time. The office accepts it for a time (possibly with a travel fee), books it as part of a trip, or declines; the homeowner is texted either way.

Body Type Required Notes
serviceId integer yes
lat, lng number yes
customer.name string yes 1 to 200 characters
customer.phone string yes 7 to 40 characters
customer.email string no
address string no Up to 300 characters
notes string no Up to 2000 characters
externalRefs, externalRef, externalSource no Your own ids for the request, as on bookings; kept on the job when the office accepts it
requested array yes 1 to 3 items, best first
requested[].date string yes Local date YYYY-MM-DD
requested[].part string no morning, afternoon or any (default)
bash
curl -X POST $CREWLINK/v1/public/northline/requests \
  -H 'content-type: application/json' \
  -d '{
    "serviceId": 2, "lat": 42.36, "lng": -87.83,
    "customer": { "name": "Pat Rivera", "phone": "847 555 0190" },
    "address": "118 Lake St, Waukegan, IL",
    "requested": [ { "date": "2026-10-20", "part": "morning" }, { "date": "2026-10-21" } ]
  }'

201

json
{ "requestId": 1043, "status": "waiting" }

When the office accepts, the request becomes a booking (same id) and the homeowner gets the usual confirmation text with a manage link. You get request.created now and booking.created later: source out_of_area_request, or source trip (with trip.booked) when it's served on a trip. The office side is in Office: requests and trips.

Errors: 404 not_found, 422 invalid_request, 429 rate_limited (30 a minute per IP, 300 per integration key).


The confirmation text links to https://go.crewlink.ai/b/{bookingId}?t={manageToken}. Your own pages can do the same with these three endpoints. Moves and cancellations follow the company's online cutoff (cancelCutoffHours, for example 24): inside it, the homeowner has to call.

#GET /v1/bookings/{id}

Name Type Notes
Path id integer bookingId
Query token string The manage token (at least 10 characters)
bash
curl "$CREWLINK/v1/bookings/1042?token=c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"
json
{
  "bookingId": 1042,
  "status": "booked",
  "company": "Northline Heating & Air",
  "slug": "northline",
  "service": "Repair visit",
  "serviceId": 2,
  "lat": 41.955,
  "lng": -87.675,
  "cancelCutoffHours": 24,
  "start": "2026-10-13T19:30:00.000Z",
  "local": "2026-10-13T14:30",
  "timezone": "America/Chicago",
  "address": "2400 N Western Ave, Chicago, IL 60647",
  "techs": ["Maria"],
  "deposit": {
    "amountCents": 4900,
    "currency": "USD",
    "status": "due",
    "provider": "stripe",
    "clientSecret": "pi_3Q..._secret_...",
    "publishableKey": "pk_test_51...",
    "testPay": false
  },
  "texts": true
}

deposit is null when the booking has none. status is due, paid or refunded. While it's due and payable online, provider, clientSecret and publishableKey work as on the booking answer; provider: null means no online payment was started (the office takes it). texts: customer texts really go out on this server; only then say "we texted you" after a move or cancel.

status is booked or cancelled for an online booking (unscheduled while it waits for the homeowner to pick a new time). slug, serviceId, lat and lng are there so you can ask availability for times to move to.

company is the name the homeowner knows the business by, the same one their texts use. techs are first names. For work one company shares with another through CrewConnect, company is the name the sending company chose, and techs is empty unless the company doing the work shares who goes.

Errors: 404 not_found (wrong id or token).

#GET /v1/bookings/{id}/deposit

The booking's deposit (as above), checked with the payment provider first: while it's due with Stripe, CrewLink asks Stripe, so a payment the browser just confirmed shows paid without waiting for Stripe's webhook. Call it after stripe.confirmPayment succeeds, and when the homeowner comes back to the manage link from a payment method that redirects.

Name Type Notes
Path id integer bookingId
Query token string The manage token
bash
curl "$CREWLINK/v1/bookings/1042/deposit?token=c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"
json
{ "amountCents": 4900, "currency": "USD", "status": "paid", "provider": "stripe", "clientSecret": null, "publishableKey": null, "testPay": false }

Errors: 404 not_found (wrong id or token, or the booking has no deposit), 429 rate_limited (30 a minute per IP).

#POST /v1/bookings/{id}/reschedule

Moves the booking. Same rules as booking: the new time is rechecked under the live schedule. The tech(s) already on the booking stay if they still fit at the new time; otherwise the best tech(s) at that time are assigned. The manage link stays the same; the homeowner gets a text with the new time and a new reminder.

Body Type Required Notes
token string yes The manage token
start string yes ISO 8601 with offset, from availability
bash
curl -X POST $CREWLINK/v1/bookings/1042/reschedule \
  -H 'content-type: application/json' \
  -d '{"token": "c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl", "start": "2026-10-15T14:00:00.000Z"}'

200: the booking as in GET /v1/bookings/{id}, with the new start, local and techs.

Status error When
404 not_found Wrong id or token, or the booking isn't active
409 too_late The current booking is inside the company's cutoff
409 too_soon The new time is less than an hour away
409 slot_taken The new time isn't free
409 outside_daylight A daylight-only service, and the new time isn't between sunrise and sunset there

#POST /v1/bookings/{id}/cancel

Body Type Required
token string yes
bash
curl -X POST $CREWLINK/v1/bookings/1042/cancel \
  -H 'content-type: application/json' -d '{"token": "c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"}'
json
{ "bookingId": 1042, "status": "cancelled" }

Queued reminders are dropped and the homeowner gets a cancellation text.

Errors: 404 not_found (wrong id or token, or already cancelled), 409 too_late (inside the cutoff).


#Picking a new time after a move

Sometimes the company has to move a booking (an emergency took the tech, or the tech is running very late) and CrewLink can't find an equally good time on its own. Then it texts the homeowner up to 3 times on different days in the next 30 days, linking to https://go.crewlink.ai/o/{jobId}?t={token}. The link works for 48 hours; after that the office calls.

#GET /v1/public/offers/{jobId}

Name Type Notes
Path jobId integer
Query token string From the link
json
{
  "company": "Northline Heating & Air",
  "service": "Repair visit",
  "status": "unscheduled",
  "timezone": "America/Chicago",
  "options": [
    { "start": "2026-10-14T15:00:00.000Z", "local": "2026-10-14T10:00" },
    { "start": "2026-10-15T19:30:00.000Z", "local": "2026-10-15T14:30" },
    { "start": "2026-10-16T13:00:00.000Z", "local": "2026-10-16T08:00" }
  ]
}

Times that have since been taken are left out; if none are left, fresh ones are offered. status turns booked once a time is picked.

Errors: 404 not_found (wrong or expired link).

#POST /v1/public/offers/{jobId}

Body Type Required Notes
token string yes From the link
start string yes One of the offered start values
json
{ "jobId": 1042, "local": "2026-10-15T14:30", "techs": ["James"] }

The homeowner gets a confirmation text with a new manage link.

Status error When
404 not_found Wrong or expired link, or a time was already picked
409 not_offered start isn't one of the offered times
409 slot_taken That time was just taken; reload the options

#GET /v1/public/invoices/{id}

The invoice the homeowner was texted (https://go.crewlink.ai/i/{invoiceId}?t={token}).

Name Type Notes
Path id integer Invoice id
Query token string From the link
json
{
  "id": 77,
  "number": 1009,
  "jobId": 1042,
  "status": "sent",
  "lines": [
    { "description": "Repair visit", "qty": 1, "unitCents": 8900 },
    { "description": "Igniter", "qty": 1, "unitCents": 4500 }
  ],
  "subtotalCents": 13400,
  "taxCents": 1072,
  "totalCents": 14472,
  "depositAppliedCents": 4900,
  "balanceCents": 9572,
  "currency": "USD",
  "company": "Northline Heating & Air",
  "payment": { "provider": "stripe", "clientSecret": "pi_3Q..._secret_..." }
}

status is sent, paid or void (a draft is never linked). payment.clientSecret confirms the card payment for balanceCents the same way as a deposit; payment is null when there's nothing to pay online, including when the company hasn't connected a payment account yet: show the balance and ask the customer to pay the company directly (the hosted page does). A void invoice (the company cancelled it) always has balanceCents 0 and payment null: show it as void with nothing to pay. When it's paid you get invoice.paid.

Errors: 404 not_found.

#POST /v1/public/reviews/{jobId}

Thirty minutes after the tech marks the job done, the homeowner is texted a review link (https://go.crewlink.ai/r/{jobId}?t={token}).

Body Type Required Notes
token string yes From the link
rating integer yes 1 to 5
comment string no Up to 2000 characters
bash
curl -X POST $CREWLINK/v1/public/reviews/1042 \
  -H 'content-type: application/json' \
  -d '{"token": "cmV2aWV3LXRva2VuLWZyb20tdGV4dA", "rating": 5, "comment": "On time and tidy."}'

201

json
{ "ok": true }

One review per job. You get review.created.

Errors: 404 not_found (wrong link), 409 already (this job has a review).

A client company (an installer that books site surveys with you, say Globex Solar) can hand its sales reps a booking link. A rep opens it on their phone, picks the service, types the homeowner's address, takes an open time and books: no account, no sign-in. The job lands on your schedule as that client's job, marked with the rep who booked it.

  • The link is an unguessable URL: https://go.crewlink.ai/r/<token> (192 random bits), or on your own domain. Only a hash of the token is used to find it.
  • The page shows the client's name ("Book a site survey · Globex Solar"), never yours or CrewLink's, and never a tech's name: its title, meta tags, link preview and icon are neutral, and it loads nothing of the office app.
  • One link per client by default (the rep types their name and email), and optional per-rep links (the rep's name and email are filled in and locked).
  • Each link has a daily cap (bookings and requests a day, your local day; default 25), an optional expiry, the services it offers (default: your residential services), and can be paused, rotated (a new URL; the old one stops at once) or revoked (it stops for good; it is kept with its bookings and history).
  • Every create, change, rotation and revocation is in the link's audit trail.

The office (an owner or a dispatcher with company scope; in the app: Clients, open a client, Booking links) and platforms with write (reads need read) manage them under the client:

Call What it does
GET /v1/office/clients/{clientId}/share-links The client's links, newest first, revoked ones included, with usage counts
POST /v1/office/clients/{clientId}/share-links Make one: { label?, rep?: { name, email }, serviceIds?, expiresAt?, dailyCap? (default 25), active? }. Without rep it is the client's general link (one per client: 409 link_exists with linkId); with rep, that rep's own (one per rep email). 409 client_inactive when the client is off
GET /v1/office/clients/{clientId}/share-links/{linkId} One link with history: [{ action: created | updated | rotated | revoked, at, by, changes }]
PATCH /v1/office/clients/{clientId}/share-links/{linkId} Change label, rep (null: back to the general link), serviceIds, expiresAt (null: never), dailyCap, active (false pauses). The URL stays the same. 409 link_revoked once revoked
POST /v1/office/clients/{clientId}/share-links/{linkId}/rotate A new URL for the same link (settings, counts and history stay); the old URL answers 404 from then on
DELETE /v1/office/clients/{clientId}/share-links/{linkId} Revoke: the URL stops working at once; the link is kept (status: "revoked", url: null)

Another company's client, or a link of another client, is 404. Field managers and people with a team or own scope get 403.

bash
curl -s -X POST "$CREWLINK/v1/office/clients/$CLIENT_ID/share-links" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"label":"Door knockers","dailyCap":25}'

Response 201 (a link object):

json
{
  "id": "c1a5e7f2-9b3d-4c8e-a6f0-2d4b8e1c7a93",
  "clientCompanyId": "7d0c4c1e-2b7a-4b8e-9a51-3f1f8a2c6b90",
  "url": "https://go.crewlink.ai/r/<token>",
  "fallbackUrl": "https://go.crewlink.ai/r/<token>",
  "label": "Door knockers",
  "kind": "client",
  "rep": null,
  "serviceIds": [],
  "services": [{ "id": 4, "name": "Site survey" }],
  "expiresAt": null,
  "dailyCap": 25,
  "active": true,
  "status": "active",
  "usage": { "bookings": 0, "requests": 0, "today": 0, "total": 0 },
  "createdVia": "office",
  "createdAt": "2026-10-13T15:30:00.000Z",
  "updatedAt": "2026-10-13T15:30:00.000Z",
  "rotatedAt": null,
  "revokedAt": null,
  "lastUsedAt": null
}

status is active, paused, expired or revoked. url is on your own domain when you have one (fallbackUrl is always the go.crewlink.ai address, which keeps working). usage.today is what dailyCap counts.

#The page and the routes it calls

The page at /r/<token> calls these public routes (no key; the token is the only credential). A token that isn't a link (or no longer is, after a rotation) is 404; a paused, expired or revoked link, or one whose client you turned off, is 410 with link_paused, link_expired, link_revoked or link_inactive and a message to show.

Call What it does
GET /v1/public/share/{token} { client: { name, logoUrl }, title, services (each with daylightOnly), rep, timezone, units, holdMinutes, dailyCapReached, turnstileSiteKey }. rep is set (and locked) on a per-rep link
GET /v1/public/geocode?share={token}&q=... Address search, biased to your area (the token stands for your slug, so the page never names you). The same exact-address rule: a street or area match is booked only with allowApproximate
POST /v1/public/share/{token}/availability { serviceId, lat, lng, from?, days?, stepMinutes? }: your open times for that client (its test flag: a test client gets your test people only), your service areas, drive time at that hour, nothing sooner than an hour from now; a daylight-only service only when the whole visit is between sunrise and sunset at the address, as on the booking page. outsideArea or no slots: offer "request a visit"
POST /v1/public/share/{token}/challenge The booking check (below)
POST /v1/public/share/{token}/holds { serviceId, lat, lng, start }: held for 10 minutes. 409 slot_taken / too_soon / outside_daylight (a daylight-only service; the message gives that day's sunrise, sunset and latest start), 429 too_many_holds or daily_cap
POST /v1/public/share/{token}/bookings { holdId, holdSecret, homeowner: { name, phone, email? }, address, geoPrecision?, allowApproximate?, notes?, rep?: { name, email }, website: "", proof }. A daylight-only service's held time is checked again (409 outside_daylight)
POST /v1/public/share/{token}/requests No time fits: { serviceId, lat, lng, address, geoPrecision?, homeowner, notes?, requested: [{ date, part }] (1 to 3), rep?, website: "", proof }: a request (status waiting) for your office to place, attributed the same way (as on the booking page, a request has days, not a time; the times your office is offered for it follow daylight only)

A booking answers 201:

json
{
  "bookingId": 1043,
  "status": "booked",
  "service": "Site survey",
  "start": "2026-10-14T14:00:00.000Z",
  "local": "2026-10-14T09:00",
  "timezone": "America/Chicago",
  "address": "1200 W Wrightwood Ave, Chicago, IL 60614",
  "bookedBy": { "repName": "Rita Rep", "repEmail": "rita@globex.example" },
  "emailed": { "rep": true, "clientAdmins": 1 }
}

No tech names: the page never shows who goes.

#What a booking records

An ordinary job of yours: clientCompanyId the client, source: "share_link", shareLinkId, and bookedBy: { repName, repEmail } (a per-rep link's own rep, whatever the page sent). They are on job reads (filedBy reads "Booked by Rita Rep via link · Globex Solar"), on booking.created and request.created and every other job webhook (additive), and on the site's timeline (by: "rep"). One site per address per client, as always.

Who hears about it. The homeowner gets your usual confirmation (and reminders, on-the-way and so on) only when the existing switches allow it: your company's homeowner messaging, and, for a link a platform made, that platform's grant (homeownerMessaging: off on the grant: nothing, ever, for those jobs). The rep and the client's owners get an email in the client's name (sender name: the client; no CrewLink, no tech names), with the service, time, address, homeowner and who booked it; on your own domain the rep's also has a "Book another" link.

#Abuse protection

  • Per IP: 30 holds, bookings, requests and checks a minute; 3 live holds.
  • Per link: 40 calls a minute (open times, holds, bookings, requests, checks), 5 live holds, and the daily cap (429 daily_cap).
  • A hidden field (website): people leave it empty; anything in it is refused (400 rejected).
  • The booking check (proof): POST .../challenge answers { challenge, difficulty, minSeconds, expiresAt }. The page finds a whole number nonce so that sha256("<challenge>:<nonce>") in hex starts with difficulty zero bits (about 16,000 tries, well under a second), and sends { challenge, nonce } with the booking or request. The challenge is signed, bound to the link, single-use, and good from minSeconds (3) after it was issued until 45 minutes after. Refusals: 403 proof_invalid, proof_too_fast, proof_used, proof_expired.
  • Optional Cloudflare Turnstile: when the server has SHARE_LINK_TURNSTILE_SITE_KEY and SHARE_LINK_TURNSTILE_SECRET, the page shows the widget and bookings need its turnstileToken (403 check_failed). No keys are needed until then.
  • Validation: the homeowner's phone (7 to 15 digits, 422 invalid_phone) and emails; the rep's name and email on a general link (422 rep_required).

#Your own domain

A platform or provider can serve its share links on its own domain, for example https://book.example.com/r/<token>, with go.crewlink.ai/r/<token> still working for the same link.

  1. DNS: an A record for the host pointing at the server's public IP (CrewLink support gives you the address). No TXT record is needed: the certificate is issued automatically over HTTP (Let's Encrypt HTTP-01) once the name resolves to the server.
  2. The server lists the host in SHARE_LINK_HOSTS (comma separated; no wildcards). The web server in front gets a site block and a certificate for each one, and the API keeps that host to the share page: /r/<token>, the public routes above, the page's own scripts and styles, a robots.txt that disallows everything and a plain favicon. Everything else there (the office app, the docs, every other API) is 404, and nothing it serves says CrewLink.
  3. Which links use it: the provider's shareLinkHost (PATCH /v1/office/settings), else the host set for the platform the provider granted (cli.ts platform-share-host <platform> <host> [mailFrom], CrewLink staff). Link objects then answer url on that host, fallbackUrl on go.crewlink.ai; the page's canonical and og:url and the "Book another" link in the rep's email use it (on the default address the page has neither, and the email no link).
  4. Emails go out with the client's name as the sender name. The From address is the provider's shareLinkMailFrom, else the platform's, else SHARE_LINK_MAIL_FROM, else the server's usual address: to send from your own domain, verify it with the mail provider (Resend) first.

CrewLink API · Reference · Changelog