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
- Embed on your website
- Company and services
- Address search
- Open times
- Hold a time
- Book the hold
- Outside the service area
- The manage link: view, move, cancel
- Picking a new time after a move
- Invoice link
- Reviews
- Client share links: booking links for a client's sales reps (and your own domain)
#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 linkTimes 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:
<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):
- Your site on the allowed origins. Add every https origin the booking screens run on (
https://www.yourcompany.com, andhttps://yourcompany.comif pages are served there too) withPATCH /v1/office/settingsallowedOrigins. 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. - The integration key (recommended). Make it with
POST /v1/office/integration-key/rotateand send it asX-CrewLink-Keyon 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.
# 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 codeThe call sequence, in your page's script:
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:
- Load the company once with
GET /v1/public/{slug}. Keep the services andunits(show distances in it). - Get coordinates for the address. Use
GET /v1/public/geocode(sendslug) or your own geocoder. Availability needslatandlng; the street address is stored separately at booking. - Ask for open times with
POST /v1/public/{slug}/availability. IfoutsideAreaistrue, show no times and offer the request form instead (nearestKmsays how far away the nearest branch is). Showlocalto the homeowner; keepstartto send back.localis the wall clock where the job is (timezone); name the zone if the visitor may be elsewhere. - Hold the chosen time with
POST /v1/public/{slug}/holds, sending the slot'sstartunchanged. On 409slot_takenortoo_soon, refresh availability and let them pick again. KeepholdIdandholdSecretin the page's memory only (not in the URL or analytics). - Collect contact details and book with
POST /v1/public/{slug}/bookingswithin 5 minutes (expiresAt). On 410hold_expired, go back to step 4 with the same time. - Take the deposit if
depositis not null (see deposits). - Show the confirmation with the
localtime and the tech first names. The homeowner also gets a text with their manage link (say so only whentextedistrue). If you show your own "manage" page, keepbookingIdandmanageTokenwith 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 |
curl $CREWLINK/v1/public/northline{
"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 |
curl "$CREWLINK/v1/public/geocode?q=2400%20N%20Western%20Ave%20Chicago&slug=northline"{
"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 |
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}'{
"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 |
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
{
"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.
{
"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 |
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
{
"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:
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) |
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
{ "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 manage link
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) |
curl "$CREWLINK/v1/bookings/1042?token=c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"{
"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 |
curl "$CREWLINK/v1/bookings/1042/deposit?token=c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"{ "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 |
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 |
curl -X POST $CREWLINK/v1/bookings/1042/cancel \
-H 'content-type: application/json' -d '{"token": "c2VjcmV0LW1hbmFnZS10b2tlbi1zaG93bi1vbmNl"}'{ "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 |
{
"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 |
{ "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 |
{
"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 |
curl -X POST $CREWLINK/v1/public/reviews/1042 \
-H 'content-type: application/json' \
-d '{"token": "cmV2aWV3LXRva2VuLWZyb20tdGV4dA", "rating": 5, "comment": "On time and tidy."}'201
{ "ok": true }One review per job. You get review.created.
Errors: 404 not_found (wrong link), 409 already (this job has a review).
#Client share links
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.
#Managing links
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.
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):
{
"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:
{
"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 .../challengeanswers{ challenge, difficulty, minSeconds, expiresAt }. The page finds a whole numbernonceso thatsha256("<challenge>:<nonce>")in hex starts withdifficultyzero 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 fromminSeconds(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_KEYandSHARE_LINK_TURNSTILE_SECRET, the page shows the widget and bookings need itsturnstileToken(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.
- DNS: an
Arecord 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. - 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, arobots.txtthat disallows everything and a plain favicon. Everything else there (the office app, the docs, every other API) is404, and nothing it serves says CrewLink. - 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 answerurlon that host,fallbackUrlongo.crewlink.ai; the page's canonical andog:urland the "Book another" link in the rep's email use it (on the default address the page has neither, and the email no link). - Emails go out with the client's name as the sender name. The From address is the provider's
shareLinkMailFrom, else the platform's, elseSHARE_LINK_MAIL_FROM, else the server's usual address: to send from your own domain, verify it with the mail provider (Resend) first.