CrewConnect API
CrewConnect lets companies, and independent contractors, send each other work. A company that has more jobs than people sends them to a partner it trusts; the partner's schedule takes them like any other booking, with the same drive-time-aware availability CrewLink uses everywhere. The sender never sees the partner's schedule, only the times the partner could take the job.
- Base URL:
https://go.crewlink.ai(api.crewlink.aiis not set up yet). For testing, ask CrewLink for an API key for a test company. - Examples use
$CREWLINKfor the base URL and$KEYfor a token. - An interactive reference for every endpoint is served at
GET /docs.
#Endpoint index
| Method | Path | Side | What it does |
|---|---|---|---|
| POST | /v1/connect/connections |
sender | Ask a company or contractor (by CrewConnect ID or booking link) to take your work |
| GET | /v1/connect/connections |
either | Your connections in both directions |
| POST | /v1/connect/connections/{id}/accept |
partner | Accept a request and choose what the sender sees |
| PATCH | /v1/connect/connections/{id} |
either | Change your own settings: visibility (partner), branding and required credentials (sender) |
| POST | /v1/connect/connections/{id}/end |
either | End a connection, or decline or withdraw a request |
| POST | /v1/connect/connections/{id}/terms |
either, owner only | Propose pay terms |
| POST | /v1/connect/connections/{id}/terms/accept |
the other side, owner only | Accept the other side's proposed terms |
| GET | /v1/connect/connections/{id}/services |
sender | The partner's services you can send |
| POST | /v1/connect/connections/{id}/availability |
sender | The partner's open times at an address |
| POST | /v1/connect/connections/{id}/jobs |
sender | Send a job into the partner's schedule |
| GET | /v1/connect/sent |
sender | Jobs you sent, with status |
| POST | /v1/connect/sent/{jobId}/cancel |
sender | Take back a job you sent |
| POST | /v1/connect/received/{jobId}/pass |
partner | Pass a job you received on to your own partner |
| GET | /v1/office/payouts |
either | What you owe partners and what partners owe you |
| POST | /v1/connect/connections/{id}/requests |
client | File a service request with your provider (client connections) |
| GET | /v1/connect/requests |
client | Requests your company filed, with status, time and the tech's first name |
| GET | /v1/connect/requests/{jobId} |
client | One of them |
| POST | /v1/connect/requests/{jobId}/cancel |
client | Withdraw or cancel one |
#Who can call these
CrewConnect endpoints need an office token: a company API key (acts as the owner), or an owner, field manager or dispatcher user token or sign-in session (see README.md and sign-in.md). Tech tokens get 403 forbidden. People with a team or own scope (office.md) may only list connections and use a client connection's services, availability and requests.
Proposing and accepting pay terms is for owners only (a company API key counts as the owner). Everything else is open to owners and dispatchers.
#The model
#Sender and partner
A connection runs one way, from a sender (who sends work) to a partner (who does it). Each side sees its own role on the connection. If two companies want to send work both ways, they make two connections, one in each direction; each has its own settings and terms.
An independent contractor is a one-person company on CrewLink (kind: "individual"). Everything on this page works the same for them.
A job you send is booked into the partner's schedule. It belongs to the partner's company: their office board shows it (marked with who sent it), their tech does it with their field app, and it follows their rules. The sender keeps a view of the jobs it sent (GET /v1/connect/sent) and can take one back.
#Connection lifecycle
stateDiagram-v2 direction LR [*] --> pending: sender asks (POST /v1/connect/connections) pending --> active: partner accepts pending --> ended: either side ends (decline or withdraw) active --> ended: either side ends ended --> pending: sender asks again
| Status | What it means |
|---|---|
pending |
The sender asked. Nothing can be sent yet, and the sender sees nothing of the partner's schedule |
active |
The partner accepted. The sender can look at open times and send work. Pay terms can be agreed |
ended |
No new work can be sent, no availability is shown, and terms can't change. Jobs already sent stay booked with the partner |
There is no separate decline call: a partner declines a request by ending it, and a sender withdraws one the same way. Ending a connection that has already ended changes nothing. To work together again, the sender asks again (POST /v1/connect/connections): the same connection goes back to pending, keeps its agreed pay terms and settings, and the partner accepts it again.
#Visibility: what the sender sees
The partner chooses, when accepting and at any time after:
visibility |
The sender sees |
|---|---|
company (default) |
Open start times, each with capacity: how many of the partner's people could take it. The partner's scheduling picks who goes. No names |
people |
Open start times per person, each with that person's id and first name. The sender may ask for a specific person (techId) when sending. Sent jobs show the first names of who's going |
Changing visibility takes effect immediately, including in the sender's list of jobs already sent.
#Branding: whose name the homeowner sees
The sender chooses whose name goes on the homeowner's texts for jobs on the connection (confirmation, reminder, on the way, review request, changes):
branding |
Texts read |
|---|---|
sender (default) |
Northline Heating & Air: your Seasonal tune-up is booked for Wed Nov 4 at 8:00 AM... (the sender's name) |
partner |
The partner's name |
A job that is passed on keeps the brand the homeowner already saw.
The homeowner's manage page (the link in their texts) follows the same rule: it shows the brand name, and the first names of the techs going only when the partner shares people visibility.
#Required credentials
The sender can require the partner's people to hold certain credentials: any of license, insurance, certification, background_check. A person is offered, or sent work, only when they hold a credential of every required kind that is valid through the date of the job (for availability: through the last day asked about). The partner records its people's credentials and expiry dates in its office (see office.md). A lapsed credential quietly removes that person from the sender's open times until it is renewed. An empty list means no requirement.
#Passing a job on (re-subcontracting)
A partner can pass a job it received on to its own partner, but only when the sender allowed it on that job (allowResubcontract: true when sending; default false). When a job is passed on:
- It is booked into the next partner's schedule, with that partner's service, under the same rules as sending a job: the connection's required credentials,
techIdonly withpeoplevisibility, and at least an hour ahead. - The middle company's visits are freed, and the job shows as
passedin the original sender's sent list. - The homeowner keeps the same brand on their texts and gets a fresh confirmation with a new manage link; the old job's pending reminder is cancelled.
- A job that was passed on can't be passed again.
- The original sender can still take it back (
POST /v1/connect/sent/{jobId}/cancel): the job booked with the next partner is cancelled with it. - When the next partner finishes it, the company in the middle is paid and pays (see Payouts).
#Pay terms
Pay terms say what the sender pays the partner for each finished job on the connection:
| Field | Type | Notes |
|---|---|---|
kind |
"percent" | "flat" |
|
value |
integer, at least 1 | percent: percent of the partner's list price for the service (1 to 100). flat: an amount in minor units (cents) in the service's currency |
netDays |
integer, 0 to 90 | Payment is due this many days after the job is marked done |
Terms are agreed in two steps, by owners only, on active connections only:
sequenceDiagram
participant A as One side (owner)
participant API as CrewLink
participant B as Other side (owner)
A->>API: POST /terms {kind, value, netDays}
API-->>A: {agreed: <old or null>, proposed: {...}, proposedByYou: true}
Note over A,B: B reads the proposal in GET /v1/connect/connections (terms.proposed)
B->>API: POST /terms/accept {the same kind, value, netDays}
API-->>B: {agreed: {...}, proposed: null, proposedByYou: false}- Either side may propose. A new proposal from either side replaces any pending one.
- Until the other side accepts, the previously agreed terms (if any) stand.
- To accept, the other side sends back the exact terms it is accepting. If they no longer match the pending proposal (because it changed meanwhile), the accept fails with
409 terms_changed, so nobody agrees to numbers they didn't see. You can't accept your own proposal.
#Payouts
When the partner's tech marks a sent job done in the field app (see field-app.md) and the connection has agreed terms, CrewLink records a payout owed by the sender to the partner:
percent: that percent of the partner's list price for the job's service, rounded to the cent. A service with no list price produces no payout.flat: the flat amount.- Due
netDaysafter the job was marked done, in the service's currency.
Terms in force when the job is marked done are the ones used. Payouts are a ledger: CrewLink records what is owed and when it is due, and the two companies settle it between them. Both sides see it with GET /v1/office/payouts.
For a job that was passed on, finishing it records two payouts, each on its own connection's terms: the original sender owes the middle company (the sender's terms, on the middle company's list price for the job it was sent), and the middle company owes the partner that did the work (the pass-on connection's terms, on that partner's list price).
#Privacy guarantees
CrewLink never shows one company another company's schedule. What each side can see, as enforced by the API:
| The sender can see | The sender can never see | |
|---|---|---|
| The partner's company | Name, booking-page slug, kind |
Its people's last names, phone numbers, locations or truck stock |
| The partner's schedule | Open start times at the address you ask about, in the partner's time zone, with capacity (crews, for a service that needs several people); per-person first names and ids only with people visibility |
The partner's other jobs, customers, addresses, drive times, working hours or calendar blocks. The open-times answer has no field that could carry them |
| The partner's services | Active services: name, minutes, people needed, days | Prices |
| Jobs you sent | Partner, service, time, status; tech first names only with people visibility |
Stage progress (on the way, arrived), notes, photos, parts. Stage webhooks go to the partner only |
| The partner can see | The partner can never see | |
|---|---|---|
| The sender's company | Name, slug, kind; which jobs it sent (sentBy on the office board) |
The sender's schedule, people, other customers or other jobs |
| Jobs it received | Everything needed to do the work, like any job in its schedule: the homeowner's name, phone, email, address and the sender's notes |
Other rules:
- A company that isn't on a connection gets
404 not_foundfor it, the same as for an id that doesn't exist. - A tech of the partner sees a received job like any other: customer first name, address and notes (see field-app.md).
- The sender's own office board never shows jobs it sent; they are in the partner's schedule.
- The
manageTokenreturned when you send a job opens the homeowner's own view of the booking (see booking-page.md). It shows the connection's brand, and tech first names only withpeoplevisibility. Treat it as the homeowner's link. - Messaging. The two offices share a channel per connection (it opens when the partner accepts, and turns read-only when the connection ends), and the thread of each job sent between them: the sender's office, the partner's office and the partner's tech(s) on the job. Neither side sees the other's company channel, office lines, direct messages or other jobs, and the sender's techs aren't in the thread. Under
companyvisibility the sender sees the partner's techs as "<Partner> tech", never by name, and the thread's on-the-way, arrived and done messages don't name them. See Messaging.
#Webhooks
CrewConnect sends these events (payloads and signing in webhooks.md). Each goes to one company's endpoints, as listed:
| Event | Sent to | When | data |
|---|---|---|---|
connection.requested |
partner | A sender asks | {connectionId, from} (sender's name) |
connection.accepted |
sender | The partner accepts | {connectionId, partner} |
connection.ended |
the other side | Either side ends (once: ending an ended connection sends nothing) | {connectionId, by} (the ending company's name) |
booking.created |
partner (or the next partner, for a pass) | A job is sent or passed on | The common booking fields with source: "crewconnect", plus sentBy |
booking.cancelled |
partner; for a passed job also the middle company | The sender takes a job back | The common booking fields with source: "crewconnect", plus by |
The common booking fields are {bookingId, source, serviceId, service, start, local, techs} (techs are person ids); see webhooks.md.
| job.en_route, job.arrived, job.completed | partner | The partner's tech moves the job | See field-app.md |
There is no webhook for proposed or accepted pay terms, and the sender gets no webhook for stage changes on jobs it sent.
#Errors
Errors always look like {"error": "<code>", "message": "<human text>"}. Every endpoint can return 401 unauthorized (no or bad token), 403 forbidden (a tech token), 422 invalid_request (validation) and 429 rate_limited (more than 600 requests a minute from one IP; wait Retry-After seconds). Endpoint-specific codes are listed with each endpoint.
#Connections
The connection object:
{
"id": "0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9",
"role": "sender",
"kind": "partner",
"company": { "name": "Acme Mechanical", "slug": "acme", "kind": "company" },
"status": "active",
"visibility": "company",
"branding": "sender",
"requiredCredentials": ["license", "insurance"],
"terms": {
"agreed": { "kind": "percent", "value": 70, "netDays": 15 },
"proposed": null,
"proposedByYou": false
}
}| Field | Notes |
|---|---|
role |
Your side: sender or partner |
kind |
partner (sharing work, this page) or client: the sender is a client company filing service requests with the partner, its provider (Client connections) |
company |
The other side. kind is company, individual or client |
status |
pending, active or ended |
visibility |
company or people. Set by the partner |
branding |
sender or partner. Set by the sender |
requiredCredentials |
Set by the sender |
terms |
Pay terms, the same object the terms endpoints return: agreed (in force, or null), proposed (pending, or null), and proposedByYou |
#POST /v1/connect/connections
Ask a company or contractor to take your work. You become the sender; they become the partner. The connection starts pending and the partner gets a connection.requested webhook. If your connection to them has ended, this asks again: the same connection (same id) goes back to pending with its agreed pay terms and settings kept, and they accept it again. Partners are found by their CrewConnect ID, which is the slug of their booking page (the {slug} in /v1/public/{slug} and in their booking link https://go.crewlink.ai/book/{slug}, see booking-page.md); ask the partner for it, or paste their booking link. Matching ignores upper and lower case.
| Body | Type | Required | Notes |
|---|---|---|---|
partner |
string | yes | Up to 500 characters. The partner's CrewConnect ID (acme), or their whole booking link (https://go.crewlink.ai/book/acme; anything after the ID, like ?utm_source=..., is ignored) |
kind |
string | no | partner (default), or client to become their client: you file service requests with them instead of sending booked jobs (Client connections). A client company can only ask as client (422) |
curl -s -X POST $CREWLINK/v1/connect/connections \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"partner":"acme"}'{
"id": "0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9",
"role": "sender",
"kind": "partner",
"company": { "name": "Acme Mechanical", "slug": "acme", "kind": "company" },
"status": "pending",
"visibility": "company",
"branding": "sender",
"requiredCredentials": [],
"terms": { "agreed": null, "proposed": null, "proposedByYou": false }
}Returns 201. Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No company with that CrewConnect ID ("No company with that CrewConnect ID.") |
| 409 | exists |
You already have a pending or active connection to them in this direction |
| 422 | self |
That's your own company ("You can't connect to your own company.") |
#GET /v1/connect/connections
All your connections, as sender and as partner, in any status.
curl -s $CREWLINK/v1/connect/connections -H "Authorization: Bearer $KEY"{
"connections": [
{ "id": "0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9", "role": "sender", "company": { "name": "Acme Mechanical", "slug": "acme", "kind": "company" }, "status": "active", "visibility": "people", "branding": "sender", "requiredCredentials": [], "terms": { "agreed": { "kind": "percent", "value": 70, "netDays": 15 }, "proposed": null, "proposedByYou": false } },
{ "id": "a4f1d3c0-2b7e-4c55-8d0a-91e6f2b7c3d8", "role": "partner", "company": { "name": "Lakeside HVAC", "slug": "lakeside", "kind": "company" }, "status": "pending", "visibility": "company", "branding": "sender", "requiredCredentials": [], "terms": { "agreed": null, "proposed": null, "proposedByYou": false } }
]
}A partner finds incoming requests here: role: "partner", status: "pending".
A company you work with both ways appears twice, once per direction, with the same company.slug. To show one entry per partner (as the CrewLink web app does), group by company.slug: the role: "sender" connection is the work you send them (its visibility is what you see of them, its requiredCredentials your rule for their people, its terms what you pay), and the role: "partner" one is the work they send you (what they see of you, their rule for your people, what they pay you).
#POST /v1/connect/connections/{id}/accept
Accept a pending request (partner only) and choose what the sender sees. The sender gets a connection.accepted webhook.
| Body | Type | Required | Notes |
|---|---|---|---|
visibility |
"company" | "people" |
no | Default company. Send {} to accept with the default |
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/accept \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"visibility":"people"}'Returns the connection with status: "active" and role: "partner". Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No request to you with that id (including when you are the sender) |
| 409 | not_pending |
It's already active or ended |
#PATCH /v1/connect/connections/{id}
Change your own settings. Each side changes only its own: the partner decides what the sender sees; the sender decides the branding and credential requirements. Works in any status. Send only the fields you are changing.
| Body | Type | Who | Notes |
|---|---|---|---|
visibility |
"company" | "people" |
partner | |
branding |
"sender" | "partner" |
sender | |
requiredCredentials |
array of "license", "insurance", "certification", "background_check" (up to 4) |
sender | Replaces the list. [] clears it |
curl -s -X PATCH $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"branding":"partner","requiredCredentials":["license","insurance"]}'Returns the updated connection. Errors:
| Status | error |
When |
|---|---|---|
| 403 | not_yours |
You tried to change the other side's setting |
| 404 | not_found |
No connection of yours with that id |
#POST /v1/connect/connections/{id}/end
End a connection, from either side and in any status. On a pending connection this is how a partner declines and how a sender withdraws. No new work can be sent; jobs already sent stay booked with the partner (the sender can still take them back). The other side gets a connection.ended webhook. Ending one that has already ended returns it unchanged, with no second webhook. No body.
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/end \
-H "Authorization: Bearer $KEY"Returns the connection with status: "ended". Errors: 404 not_found (no connection of yours with that id).
#Pay terms
Both terms endpoints return:
{
"agreed": { "kind": "percent", "value": 70, "netDays": 15 },
"proposed": null,
"proposedByYou": false
}| Field | Notes |
|---|---|
agreed |
The terms in force, or null |
proposed |
A pending proposal, or null |
proposedByYou |
true if the pending proposal is yours |
#POST /v1/connect/connections/{id}/terms
Propose pay terms (either side, owner only, active connections only). Replaces any pending proposal from either side. The agreed terms don't change until the other side accepts.
| Body | Type | Required | Notes |
|---|---|---|---|
kind |
"percent" | "flat" |
yes | |
value |
integer, at least 1 | yes | Percent (at most 100), or minor units for flat |
netDays |
integer, 0 to 90 | yes |
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/terms \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"kind":"percent","value":70,"netDays":15}'{ "agreed": null, "proposed": { "kind": "percent", "value": 70, "netDays": 15 }, "proposedByYou": true }Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
You are not an owner |
| 404 | not_found |
No connection of yours with that id |
| 409 | not_active |
The connection is pending or ended |
| 422 | invalid_request |
A percent over 100, value under 1, or netDays outside 0 to 90 |
#POST /v1/connect/connections/{id}/terms/accept
Accept the other side's pending proposal (owner only, active connections only). Send back the terms you are accepting, exactly as proposed.
| Body | Type | Required | Notes |
|---|---|---|---|
kind |
"percent" | "flat" |
yes | Must match the pending proposal |
value |
integer | yes | Must match |
netDays |
integer | yes | Must match |
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/terms/accept \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"kind":"percent","value":70,"netDays":15}'{ "agreed": { "kind": "percent", "value": 70, "netDays": 15 }, "proposed": null, "proposedByYou": false }Errors:
| Status | error |
When |
|---|---|---|
| 403 | forbidden |
You are not an owner |
| 404 | not_found |
No connection of yours with that id |
| 409 | not_active |
The connection is pending or ended |
| 409 | nothing_to_accept |
No pending proposal, or the pending one is your own |
| 409 | terms_changed |
The terms you sent don't match the pending proposal (it changed meanwhile). Look again before accepting |
| 422 | invalid_request |
Missing or malformed terms |
#Sending work
The sender's flow on an active connection:
sequenceDiagram
participant S as Sender
participant API as CrewLink
S->>API: GET /connections/{id}/services
S->>API: POST /connections/{id}/availability {serviceId, lat, lng, from, days}
API-->>S: open times (start, local, capacity, tech?)
S->>API: POST /connections/{id}/jobs {serviceId, lat, lng, start, customer, ...}
API-->>S: 201 {jobId, start, local, techs, manageToken}
Note over API: booked into the partner's schedule, homeowner texted
S->>API: GET /sent?from=&to= (status)#GET /v1/connect/connections/{id}/services
The partner's active services: what you can send. Sender only, active connections only.
{
"services": [
{ "id": 7, "name": "Seasonal tune-up", "minutes": 60, "peopleNeeded": 1, "days": 1 },
{ "id": 9, "name": "Furnace install", "minutes": 420, "peopleNeeded": 2, "days": 2 }
]
}| Field | Notes |
|---|---|
minutes |
The partner's standard length for the service |
peopleNeeded |
How many people go |
days |
Consecutive days the job takes |
Errors: 404 not_found (no active connection where you are the sender).
#POST /v1/connect/connections/{id}/availability
The partner's open start times for one of its services at an address. Sender only, active connections only. Each time is one the partner's people can actually make, counting their other jobs, calendars and the drive at that time of day; nothing sooner than an hour from now. People who don't meet the connection's required credentials are left out.
| Body | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes | One of the partner's services |
lat |
number, -90 to 90 | yes | The job's location |
lng |
number, -180 to 180 | yes | |
from |
string YYYY-MM-DD |
no | First day, in the time zone of the partner's branch serving the address (timezone in the answer). Default: today there |
days |
integer, 1 to 14 | no | Default 7 |
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/availability \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"serviceId":7,"lat":41.955,"lng":-87.675,"from":"2026-11-04","days":1}'company visibility:
{
"timezone": "America/Chicago",
"times": [
{ "start": "2026-11-04T14:00:00.000Z", "local": "2026-11-04T08:00", "capacity": 3 },
{ "start": "2026-11-04T14:30:00.000Z", "local": "2026-11-04T08:30", "capacity": 2 }
]
}people visibility (one entry per person who can make that time):
{
"timezone": "America/Chicago",
"times": [
{ "start": "2026-11-04T14:00:00.000Z", "local": "2026-11-04T08:00", "capacity": 1, "tech": { "id": 12, "name": "Maria" } },
{ "start": "2026-11-04T14:00:00.000Z", "local": "2026-11-04T08:00", "capacity": 1, "tech": { "id": 15, "name": "Luis" } }
]
}| Field | Notes |
|---|---|
timezone |
The partner's IANA time zone |
start |
The start as an instant (UTC) |
local |
The same start on the partner's wall clock |
capacity |
How many jobs of this service the partner could take at that time: for a one-person service, how many of their people are free; for a service that needs several people (peopleNeeded), how many full crews. Always 1 with people visibility (one entry per person) |
tech |
Only with people visibility: who, by id and first name |
For a service that needs several people, a time is listed only when at least one full crew is free then. With people visibility, each free person is listed only at times when enough of their colleagues are free too; naming one with techId sends them and CrewLink adds the rest of the crew.
Errors: 404 not_found (no active connection where you are the sender, or no such service at this partner).
#POST /v1/connect/connections/{id}/jobs
Send a job. It is booked straight into the partner's schedule, rechecked against the partner's live schedule under a lock so two senders (or the partner's own booking page) can never get the same slot. CrewLink picks the best person at that time, or the person you named (people visibility only). The homeowner gets a confirmation text and a reminder 24 hours before, carrying the connection's brand, and the partner gets a booking.created webhook.
| Body | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes | One of the partner's services |
lat |
number, -90 to 90 | yes | The job's location |
lng |
number, -180 to 180 | yes | |
start |
string, ISO 8601 with offset | yes | Usually a start from availability. At least an hour from now |
techId |
integer | no | Only with people visibility: the person you want (a tech.id from availability) |
customer.name |
string, 1 to 200 characters | yes | The homeowner |
customer.phone |
string, 7 to 40 characters | yes | Texts go to US and Canadian mobile numbers |
customer.email |
string (email) | no | |
address |
string, up to 300 characters | no | Shown to the partner and its tech |
notes |
string, up to 2000 characters | no | Shown to the partner and its tech |
allowResubcontract |
boolean | no | Default false. May the partner pass this job on to its own partners? |
allowQueue |
boolean | no | Default false. true: when the partner can't take it then (too_soon or slot_taken below), it goes into the partner's intake queue instead: 202 with status: "unscheduled", the homeowner is texted that a time is coming, and the partner's office places it |
curl -s -X POST $CREWLINK/v1/connect/connections/0c9e7a52-5d1b-4b8e-9f0e-3a6c2d71e4b9/jobs \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"serviceId": 7,
"lat": 41.955, "lng": -87.675,
"start": "2026-11-04T14:00:00.000Z",
"techId": 12,
"customer": { "name": "Riley Stone", "phone": "312 555 0190" },
"address": "2150 W Roscoe St, Chicago, IL",
"notes": "Dog in the yard",
"allowResubcontract": false
}'{
"jobId": 2207,
"start": "2026-11-04T14:00:00.000Z",
"local": "2026-11-04T08:00",
"techs": ["Maria"],
"manageToken": "pX3v0b2m3QZ6h9Tq1nYc5rWk8aLd4FeG"
}| Field | Notes |
|---|---|
jobId |
The job's id. The same id appears in your sent list, in the partner's schedule, in webhooks and in payouts |
local |
The partner's wall clock |
techs |
First names of who's going, with people visibility only; otherwise [] |
manageToken |
The homeowner's manage-link token (GET /v1/bookings/{jobId}?token=..., see booking-page.md). The homeowner already gets this link in their confirmation text; keep it only if you show the homeowner the link yourself |
Errors. A refusal also carries a reason saying why, the way office moves do, so you can show the sender what to do next. The message names the partner company and the credentials you require, never the partner's people (unless you picked one, in people visibility) or its other jobs.
{
"error": "slot_taken",
"reason": "credential",
"message": "Nobody at Sunline Solar who could go then holds the credentials you require (license), valid on the job date. Pick another time, or change what you require."
}| Status | error |
reason |
When |
|---|---|---|---|
| 404 | not_found |
No active connection where you are the sender, or no such service at this partner | |
| 409 | client_connection |
A client connection: file a request instead (POST .../requests) |
|
| 409 | too_soon |
too_soon |
start is less than an hour from now |
| 409 | slot_taken |
credential |
The people who could go then don't hold the credentials you require (see Required credentials), or the person you named doesn't. Pick another time or change the rule |
| 409 | slot_taken |
outside_hours |
Outside the working hours of everyone who could go (or of the person you named) |
| 409 | slot_taken |
no_capacity |
Nobody is free then any more (or not the person you named): another job or the drive is in the way. Fetch availability again |
| 409 | slot_taken |
out_of_area |
The address is outside every service area of the partner, or outside the area of the tech you named (the same rule as the partner's own New job: a branch's areaKm around its location, or the radius of someone without a branch). Ask the partner first, or send it to another partner |
| 422 | invalid_request |
visibility |
techId on a company-visibility connection ("This partner shares open times only; they choose who goes.") |
| 422 | invalid_request |
Validation (no reason) |
With allowQueue: true, the 409 refusals become 202:
{ "jobId": 2208, "status": "unscheduled", "queued": true, "requested": { "start": "2026-11-04T14:00:00.000Z", "local": "2026-11-04T08:00" }, "reason": "Sunline Solar has no one free then any more. Pick another of their open times." }The job is then on the partner's side, waiting for a time: it isn't in your sent list until they book it, and you can't see their queue. The partner books it like any job in their intake queue.
#GET /v1/connect/sent
The jobs you sent, in start order, by the day they start on the partner's wall clock.
| Query | Type | Required | Notes |
|---|---|---|---|
from |
string YYYY-MM-DD |
yes | First day |
to |
string YYYY-MM-DD |
yes | Last day (inclusive) |
curl -s "$CREWLINK/v1/connect/sent?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"{
"jobs": [
{ "jobId": 2207, "partner": "Acme Mechanical", "service": "Seasonal tune-up", "start": "2026-11-04T14:00:00.000Z", "local": "2026-11-04T08:00", "status": "booked", "techs": ["Maria"] },
{ "jobId": 2231, "partner": "Dana Ortiz", "service": "Seasonal tune-up", "start": "2026-11-10T15:00:00.000Z", "local": "2026-11-10T09:00", "status": "passed", "techs": [] }
]
}| Field | Notes |
|---|---|
status |
booked (on the partner's schedule, including once the work is done), cancelled, passed (the partner passed it on), or unscheduled (the partner had to take it off the schedule and is finding a new time with the homeowner) |
techs |
First names of who's going, only when the connection has people visibility; otherwise [] |
Errors: 422 (missing or malformed dates).
#POST /v1/connect/sent/{jobId}/cancel
Take back a job you sent, before its start time and before it's done. It comes off the partner's schedule, the homeowner is texted that it's cancelled (with the connection's brand) and their reminder is dropped, and the partner gets a booking.cancelled webhook. Works after the connection has ended. No body.
A job your partner passed on (status: "passed") can be taken back too: the job booked with their partner is cancelled with it (its visits freed, the homeowner texted, that partner sent booking.cancelled), and the company in the middle gets booking.cancelled for the job it passed on.
curl -s -X POST $CREWLINK/v1/connect/sent/2207/cancel -H "Authorization: Bearer $KEY"{ "jobId": 2207, "status": "cancelled" }Errors:
| Status | error |
When |
|---|---|---|
| 404 | not_found |
No booked or passed job you sent with that id |
| 409 | too_late |
The job is done, or its start time has passed |
#Passing work on
#POST /v1/connect/received/{jobId}/pass
Pass a job you received on to one of your partners (you are the sender on that second connection). Only when the original sender allowed it on that job. See Passing a job on for what happens.
| Path | Type | Notes |
|---|---|---|
jobId |
integer | A job you received through CrewConnect, still booked in your schedule |
| Body | Type | Required | Notes |
|---|---|---|---|
connectionId |
UUID | yes | Your active connection to the next partner, where you are the sender |
serviceId |
integer | yes | The next partner's service |
start |
string, ISO 8601 with offset | no | Default: the job's current time. At least an hour from now |
techId |
integer | no | Only if the next partner shares people visibility with you |
curl -s -X POST $CREWLINK/v1/connect/received/2231/pass \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"connectionId":"5b2e9d41-7c0a-4f3e-b6d8-0e1a2c3f4b5d","serviceId":14}'{ "jobId": 2240, "start": "2026-11-10T15:00:00.000Z", "local": "2026-11-10T09:00" }jobId is the new job in the next partner's schedule; local is on the next partner's wall clock. The next partner gets a booking.created webhook with sentBy set to your company name.
Errors:
| Status | error |
When |
|---|---|---|
| 403 | not_allowed |
The original sender didn't allow passing this job on, or it was already passed on to you |
| 404 | not_found |
No booked job you received with that id; no active connection with that id where you are the sender; or no such service at that partner |
| 409 | too_soon |
The start is less than an hour from now |
| 409 | slot_taken |
Nobody at the next partner who meets that connection's required credentials is free then (or not the person you named), or nobody there whose service area covers the address (a named tech must cover it too) |
| 422 | invalid_request |
Validation, or techId when the next partner shares open times only |
#Payouts
#GET /v1/office/payouts
Every CrewConnect payout you are on, either direction. Open to owners and dispatchers.
curl -s $CREWLINK/v1/office/payouts -H "Authorization: Bearer $KEY"{
"payouts": [
{
"id": "e3c1a9b2-6f4d-4a8e-b1c7-5d2f0e9a8b7c",
"direction": "owed",
"counterparty": "Acme Mechanical",
"jobId": 2207,
"amountCents": 9030,
"currency": "USD",
"status": "scheduled",
"dueAt": "2026-11-19T15:10:00.000Z"
}
]
}| Field | Notes |
|---|---|
direction |
owed: you are the sender and owe this. incoming: you are the partner and are owed it. A company that passed a job on has one of each for it |
counterparty |
The other company's name |
jobId |
The job it is for |
amountCents |
Minor units, in currency |
status |
scheduled: recorded and due on dueAt |
dueAt |
When the job was marked done plus the agreed netDays |
The example is 70% of a $129.00 list price, net 15.
#Client connections
A client is a company that files service requests with a provider instead of doing the work itself: an installer booking site surveys, for example. It connects to its provider with a connection of kind: "client" (the client is the sender, the provider the partner). A provider usually sets its clients up itself and invites their people (office.md); an existing CrewLink company can also ask with POST /v1/connect/connections {"partner":"northline","kind":"client"} and the provider accepts it.
What a client sees on a client connection:
- the provider's services (
GET .../services) and open times at an address (POST .../availability), exactly as a sender does; - the requests it filed: status, the visit time once scheduled, and the tech's first name. Never the provider's other jobs, customers or people.
What changes compared with sending work:
- A request is never refused for lack of a time. Without
startit waits for the provider to schedule it (pending); with astartnobody can make, it is filed aspendingtoo, with thereason. - The job belongs to the provider's schedule and to the client's customer: the provider sees
clientandfiledByon it, and it counts on the provider's plan. It isn't a CrewConnect job (no pay terms, payouts or passing on). - Inside the client company, who sees which request follows each person's scope (office.md): owners all of them, a field manager their team's, a dispatcher their own (the default at a client company).
#POST /v1/connect/connections/{id}/requests
File a service request on an active client connection where you are the client.
| Body | Type | Required | Notes |
|---|---|---|---|
serviceId |
integer | yes | One of the provider's active services |
lat, lng |
number | yes | Where the work is |
address |
string | no | Up to 300 characters |
customer |
object | yes | name (1 to 200), phone (7 to 40), email (optional): the homeowner |
notes |
string | no | Up to 2000 characters |
start |
string | no | ISO datetime with offset: book this time if someone can go (one of the provider's open times, at least an hour ahead) |
requested |
array | no | Up to 3 { date, part } (part: morning, afternoon or any, default any): days that suit the homeowner, for a pending request. With a start that couldn't be booked, that day is used |
curl -s -X POST $CREWLINK/v1/connect/connections/$CONN/requests \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"serviceId":3,"lat":41.955,"lng":-87.675,"address":"1 Solar Way, Chicago","customer":{"name":"Hank Owner","phone":"312 555 0142"},"requested":[{"date":"2026-11-03","part":"morning"}]}'Response 201:
{ "requestId": 1407, "status": "pending", "start": null, "local": null, "timezone": "America/Chicago", "techs": [], "reason": null }Booked at a start:
{ "requestId": 1408, "status": "scheduled", "start": "2026-11-03T15:30:00.000Z", "local": "2026-11-03T09:30", "timezone": "America/Chicago", "techs": [], "reason": null }| Field | Notes |
|---|---|
status |
scheduled (booked at start) or pending (waiting for the provider) |
timezone |
The zone of local and of the requested days: the provider's branch serving the address |
techs |
Always empty: a client never sees who does the work, no tech or staff names (kept so older apps keep working) |
reason |
Why a start wasn't booked: too_soon (under an hour ahead), credential (nobody who could go holds the credentials you require), outside_hours, or no_capacity. null otherwise |
The provider gets request.created (pending) or booking.created (scheduled), both with client and filedBy (webhooks.md). A scheduled request texts the homeowner the same confirmation as any booking. Errors: 404 not_found when the connection isn't an active client connection of yours, or the service isn't the provider's.
#GET /v1/connect/requests
Requests your company filed, newest first.
| Query | Notes |
|---|---|
status |
One or more of pending, scheduled, done, cancelled, declined, comma-separated |
from, to |
The date filed, local dates YYYY-MM-DD, inclusive |
page, limit |
Default page 1, 25 a page, up to 100 |
{
"requests": [
{
"requestId": 1408,
"provider": "Northline Heating & Air",
"service": "Site survey",
"customer": { "name": "Ivy Owner", "phone": "312 555 0142" },
"address": "2 Solar Way, Chicago",
"notes": null,
"status": "scheduled",
"start": "2026-11-03T15:30:00.000Z",
"local": "2026-11-03T09:30",
"timezone": "America/Chicago",
"techs": [],
"requested": [],
"filedBy": { "userId": "5c2d9e1f-7a4b-4c8d-9e0f-1a2b3c4d5e6f", "name": "Dev Two" },
"source": "client",
"createdAt": "2026-10-12T15:30:00.000Z"
}
],
"page": 1, "limit": 25, "total": 1, "totalPages": 1
}| Field | Notes |
|---|---|
status |
pending (filed, no visit yet: waiting for the provider to pick a day, in the provider's intake queue, or taken off the board; start and local are null), scheduled (a visit is on the provider's board), done, cancelled, declined (the provider turned it down). The status filter uses the same rule |
start, local, timezone |
The visit once scheduled: UTC, and the wall clock where the work is (timezone, the provider's branch serving the address) |
techs |
Always empty: a client never sees who does the work, no tech or staff names (kept so older apps keep working) |
filedBy |
The person at your company who filed it (userId, name); when the provider's office filed it for you, { "userId": null, "name": "<the provider's name>" } (never a provider person's name); null for your API key or the provider's API key |
source |
client (a person), api (your API key), or office / api when the provider booked it for you |
#GET /v1/connect/requests/{jobId}
One request, the same object. 404 when it isn't one of yours, or not in your scope.
#POST /v1/connect/requests/{jobId}/cancel
Take a request back: a pending one is withdrawn, a scheduled one is cancelled on the provider's schedule (the homeowner is texted, unless the job's homeowner texts are off; the provider gets booking.cancelled). A scheduled request can be taken back up to the provider's cancelCutoffHours before its start (default 24, the same cutoff as the homeowner's manage link); inside it, 409 too_late (call the provider: its office has no cutoff). Returns { "requestId": 1408, "status": "cancelled" }. 409 too_late also once it's done or its start has passed; 409 not_open when it's already cancelled or declined; 404 when it isn't yours or not in your scope.
#The client's API key
A provider can issue a client company it set up an API key (office.md). It acts as the client's owner: it files requests (source: "api") and reads all of them, and sees nothing of the provider's schedule.
#Walkthrough
- Sender asks:
POST /v1/connect/connections {"partner":"acme"}. The partner getsconnection.requested. - Partner finds it in
GET /v1/connect/connections(role: "partner",status: "pending") and accepts:POST .../accept {"visibility":"company"}. The sender getsconnection.accepted. - Sender sets its rules:
PATCH .../{id} {"branding":"sender","requiredCredentials":["license","insurance"]}. - Owners agree terms: one side
POST .../terms {"kind":"percent","value":70,"netDays":15}, the otherPOST .../terms/acceptwith the same three values. Send back theterms.proposedyou showed the person (fromGET /v1/connect/connections), not a copy you kept: if the other side changed it meanwhile you get409 terms_changedinstead of accepting terms nobody looked at. - Sender sends work:
GET .../services,POST .../availability, thenPOST .../jobswith astartfrom the list. - Partner's tech does it with the field app; on
done, the payout appears for both sides inGET /v1/office/payouts. - Sender follows along with
GET /v1/connect/sent, and can take a job back withPOST /v1/connect/sent/{jobId}/cancel.
#Known limitations
- No invite codes. Partners are found only by their CrewConnect ID (their booking-page slug).
- Payouts aren't paid through CrewLink. They are recorded with a due date; the companies settle them directly, and there is no call to mark one paid.
- Payouts need the field app. A payout is recorded when the partner's tech marks the job done in the field app.