CrewLink API

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.ai is not set up yet). For testing, ask CrewLink for an API key for a test company.
  • Examples use $CREWLINK for the base URL and $KEY for 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, techId only with people visibility, and at least an hour ahead.
  • The middle company's visits are freed, and the job shows as passed in 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 netDays after 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_found for 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 manageToken returned 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 with people visibility. 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 company visibility 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:

json
{
  "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)
bash
curl -s -X POST $CREWLINK/v1/connect/connections \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"partner":"acme"}'
json
{
  "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.

bash
curl -s $CREWLINK/v1/connect/connections -H "Authorization: Bearer $KEY"
json
{
  "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
bash
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
bash
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.

bash
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:

json
{
  "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
bash
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}'
json
{ "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
bash
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}'
json
{ "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.

json
{
  "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
bash
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:

json
{
  "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):

json
{
  "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
bash
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
  }'
json
{
  "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.

json
{
  "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:

json
{ "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)
bash
curl -s "$CREWLINK/v1/connect/sent?from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer $KEY"
json
{
  "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.

bash
curl -s -X POST $CREWLINK/v1/connect/sent/2207/cancel -H "Authorization: Bearer $KEY"
json
{ "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
bash
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}'
json
{ "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.

bash
curl -s $CREWLINK/v1/office/payouts -H "Authorization: Bearer $KEY"
json
{
  "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 start it waits for the provider to schedule it (pending); with a start nobody can make, it is filed as pending too, with the reason.
  • The job belongs to the provider's schedule and to the client's customer: the provider sees client and filedBy on 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
bash
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:

json
{ "requestId": 1407, "status": "pending", "start": null, "local": null, "timezone": "America/Chicago", "techs": [], "reason": null }

Booked at a start:

json
{ "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
json
{
  "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

  1. Sender asks: POST /v1/connect/connections {"partner":"acme"}. The partner gets connection.requested.
  2. Partner finds it in GET /v1/connect/connections (role: "partner", status: "pending") and accepts: POST .../accept {"visibility":"company"}. The sender gets connection.accepted.
  3. Sender sets its rules: PATCH .../{id} {"branding":"sender","requiredCredentials":["license","insurance"]}.
  4. Owners agree terms: one side POST .../terms {"kind":"percent","value":70,"netDays":15}, the other POST .../terms/accept with the same three values. Send back the terms.proposed you showed the person (from GET /v1/connect/connections), not a copy you kept: if the other side changed it meanwhile you get 409 terms_changed instead of accepting terms nobody looked at.
  5. Sender sends work: GET .../services, POST .../availability, then POST .../jobs with a start from the list.
  6. Partner's tech does it with the field app; on done, the payout appears for both sides in GET /v1/office/payouts.
  7. Sender follows along with GET /v1/connect/sent, and can take a job back with POST /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.

CrewLink API · Reference · Changelog