CrewLink API

Messaging API

Messages between the people of a company, and between two CrewConnect offices: a company channel everyone reads, a thread on every job, each tech's line to the office, direct messages, and a channel with each CrewConnect partner. Who can message whom follows the company's rules, and a CrewConnect partner only ever sees what you share with them.

Customers are not in here. They hear from you by text (Office: texts); messaging is for your team.

Conversations a platform mirrors into CrewLink are not in here either: they are an office-only log on the job, never notify anyone, and techs never see them (platforms.md).

All endpoints are under /v1/messaging and tagged Messaging in the reference.

#Endpoint index

Method and path What
GET /v1/messaging/conversations Your conversations with unread counts
GET /v1/messaging/unread Unread totals, for a badge
GET /v1/messaging/updates What changed since a cursor (poll this)
GET /v1/messaging/search Search messages you can read
GET /v1/messaging/recipients Who you can start a direct message with
GET /v1/messaging/conversations/{id} One conversation and its members
GET /v1/messaging/conversations/{id}/messages Messages, paged
POST /v1/messaging/conversations/{id}/messages Post a message
POST /v1/messaging/conversations/{id}/read Mark read
POST /v1/messaging/conversations/{id}/mute Mute or unmute
POST /v1/messaging/conversations/{id}/attachments Get an upload URL for a photo or PDF
PATCH /v1/messaging/messages/{id} Edit your message (15 minutes)
DELETE /v1/messaging/messages/{id} Delete your message (15 minutes)
POST /v1/messaging/dms Start or reopen a direct message
GET /v1/messaging/jobs/{jobId} A job's thread
POST /v1/messaging/jobs/{jobId}/messages Post in a job's thread by job id
GET /v1/messaging/office-line A tech's line to the office
POST /v1/messaging/office-line/messages A tech posts to the office
GET /v1/messaging/settings The company's messaging rules
PUT /v1/messaging/settings Change them (owner)

#Who can call these

Messaging is between people. Use a person's token: a sign-in session or a user token (owner, dispatcher or tech). The company API key acts for the company, not a person, so it gets 403 user_required everywhere here except the settings, which it can read and change (it acts as the owner).

#Conversations

Kind What Members
master "Everyone", the company channel Everyone at the company. The office posts; techs read (unless the owner lets everyone post)
job One thread per job The office, the tech(s) on the job, and for a job sent through CrewConnect the other company's office
office A tech's line to the office, one per tech That tech and everyone in the office
dm A direct message between two people The two of them, nobody else (not even the owner)
partner One per pair of CrewConnect companies (connected one way or both ways) The two companies' offices

A conversation's title is what to call it from where you sit: the other person for a direct message, the partner company for a partner channel, "Everyone", "Office" for a tech's own office line (the tech's name for the office), or "Job #1042 · Repair visit".

The company channel, a tech's office line and each active partner channel always exist. A job's thread is made the first time someone opens it or something happens on the job, and from then on it fills in by itself (see system messages).

#Who can message whom

These are the rules, the same everywhere (web, field app and this API). "Office" means owners and dispatchers.

Direct messages (POST /v1/messaging/dms, GET /v1/messaging/recipients):

From \ To Owner Dispatcher Tech
Owner yes yes yes
Dispatcher yes yes yes
Tech yes yes only when the owner turns on techs can message each other

Never with someone at another company, and never with yourself.

Reading and posting, by where you stand (R = read and post, r = read only, - = can't see it):

Kind Owner Dispatcher Tech in it Other tech Partner's office Partner's tech Another company
master R R r (R if everyone can post) r (R if everyone can post) - - -
office R R R (their own line) - - - -
job R R R (on the job) - R - -
partner R (r once ended) R (r once ended) - - R (r once ended) - -
dm R if one of the two R if one of the two R if one of the two (r for tech-to-tech when that setting is off) - - - -

What that means in practice:

  • A tech sees other techs only in the threads of jobs they share, unless the owner allows direct messages between techs. Turning that setting off later leaves existing tech-to-tech conversations readable but closed (403 techs_off on post).
  • A tech is in a job's thread while they're on the job. Moved off it, they leave the thread. When a job is cancelled or passed on to a partner, the techs who were on it keep reading its thread.
  • A field manager or dispatcher with a team or own scope is in the threads of the jobs they see only (the ones they or their team filed): another job's thread is 403 not_member and never in their inbox, unread counts or search.
  • A CrewConnect partner sees the thread of each job sent between you and the channel between your two offices, and nothing else of yours: not the company channel, office lines, direct messages or other jobs. Their techs never see your partner channel.
  • People who leave the company (their sign-in is turned off) leave every conversation.

Asking for a conversation you can't see: 404 not_found when it belongs to another company (so ids can't be probed), 403 not_member when it's your company's but not yours.

#What a tech sees of other people

A tech sees the other techs of their own company as "First L." (first name, last initial: "Maria G."): as members, as senders, in mentions and inbox previews, as a direct message's title and in GET /v1/messaging/recipients. Their own name and the office's names are shown in full. The office (owners, dispatchers, field managers) always sees full names.

A tech never sees a client company's name: when a client company cancels a job, the job thread's system message reads "Cancelled by the client." for techs and "Cancelled by <client>." for the office.

#Names across companies

In a thread shared through CrewConnect, people at the other company show as "First (Company)". A client company (kind: "client") sees its provider's people as the provider's name only, never a person's name, and the message.posted webhook to a client's endpoints says the same. A tech sees other techs as first name and last initial ("Maria G."), and never a client's or a partner's company name ("a partner"): see SECURITY.md. When the partner shares open times only (connection visibility company), the sender never learns the partner's techs' names: their messages show as "<Company> tech", and system messages say "The tech is on the way." sender.userId and mentions[].userId are null for people at the other company.

#Messaging settings

GET /v1/messaging/settings (anyone) and PUT /v1/messaging/settings (owner, or the company API key):

json
{ "techsCanMessageTechs": false, "everyoneCanPostInMaster": false }

Both default to false. PUT takes either or both and returns the result. Anyone other than an owner gets 403 forbidden.

#Unread, mentions and muting

  • unread on a conversation counts messages from other people after the last one you read. System messages never count.
  • mentions counts the unread ones that @mention you. Mention someone by sending their user id in mentions (what a composer's picker does), or by writing @Full Name in the text, or @First when only one member has that first name. Only members of the conversation can be mentioned.
  • A muted conversation still shows its counts, but adds only its mentions to the badge total.
  • Your own messages are read as you send them.

#System messages

CrewLink writes quiet kind: "system" messages into a job's thread as the job changes, so the thread is the job's story:

When Text (examples)
Booked (any source) "Booked online for Tue Oct 13, 9:00 AM." / "Booked by the office for ..." / "Booked as a recurring visit for ..."
Sent through CrewConnect "Sent by Northline Heating & Air through CrewConnect for Tue Oct 13, 11:00 AM."
Moved "Moved to Thu Oct 15, 10:00 AM (was Wed Oct 14, 10:00 AM): an earlier job ran long."
Taken off the schedule "Taken off the schedule (an emergency came in); the customer is picking a new time."
On the way, arrived, done "Maria is on the way (about 12 min out)." / "Maria arrived." / "Maria finished the job (64 min on site)."
Mid-job check-in "Maria checked in: on track." / "Maria checked in: needs 30 more minutes."
Cancelled "Cancelled by the customer." / "Cancelled: Customer sold the house." / "Cancelled by Lakeshore Installers." (a client company; techs read "Cancelled by the client.")

A tech's job note from the field app (POST /v1/tech/jobs/{id}/notes) also shows in the thread, as a kind: "note" message from them. Accepting a CrewConnect connection opens the partner channel with a system message. System messages have no sender, can't be edited, and don't count as unread.

#Getting new messages

Poll GET /v1/messaging/updates: call it once without since to get a cursor, then with since=<cursor> every few seconds while a conversation is open and every 30 seconds or so for the badge. Each answer has the messages posted, edited or deleted since the cursor (in conversations you read), the summaries of the conversations they're in, your unread totals and the next cursor. Merge messages by id: an edited or deleted message comes back with the same id.

If you only need the badge, GET /v1/messaging/unread is the cheap call.

#Posting

POST /v1/messaging/conversations/{id}/messages, or by job id (POST /v1/messaging/jobs/{jobId}/messages), or a tech to the office (POST /v1/messaging/office-line/messages). The last two don't need the conversation's id, which suits an app that queues messages while offline.

json
{
  "body": "@Maria Gomez gate code is 4411",
  "kind": "text",
  "mentions": ["3f6a9e1c-8d2b-4c7e-a1f0-9b8c7d6e5f40"],
  "attachments": []
}
  • body up to 4,000 characters; it may be empty when there's an attachment.
  • kind: text (default) or note (job threads only).
  • Idempotency. Send an Idempotency-Key header (a UUID per message). A retry with the same key and body returns the first answer with Idempotent-Replayed: true instead of posting twice, for 24 hours (Field app: offline taps).
  • Rate limit. 60 posts a minute per person; then 429 rate_limited with Retry-After.
  • Edit and delete your own message for 15 minutes after you send it (canEdit says whether you still can). A deleted message stays in the thread as deleted, with no text or files.

#Attachments

Photos (JPEG, PNG, HEIC, WebP) and PDFs, up to 20 MB each and 5 per message:

  1. POST /v1/messaging/conversations/{id}/attachments with { "contentType": "image/jpeg", "name": "panel.jpg", "size": 482113 } returns { key, uploadUrl, expiresInSeconds }.
  2. PUT the bytes to uploadUrl with the same Content-Type, within 15 minutes.
  3. Post a message with attachments: [{ key, name, contentType, size }].

Messages come back with a download url for each attachment, good for 15 minutes (fetch the message again for a fresh one). Only someone who may post in the conversation gets an upload URL, and a key from another conversation is refused (422 bad_attachment). When you post, CrewLink checks each file in storage: nothing uploaded at the key, a file bigger than its size or of another type than its contentType is 422 bad_attachment (a mismatched file is deleted; upload it again). Deleting a message deletes its files from storage.

#Errors

Besides the usual ones:

Status error When
403 user_required The company API key on anything but settings
403 not_member A conversation at your company you aren't in
403 office_only A tech posting in the company channel while only the office may
403 techs_off Tech-to-tech direct message while the owner hasn't allowed it
403 ended Posting in the channel of an ended CrewConnect connection
403 not_yours Editing or deleting someone else's message
404 not_found Unknown, or another company's
409 too_late Editing or deleting after 15 minutes
409 deleted Editing a deleted message
422 empty No text and no attachment
422 bad_attachment An attachment key not uploaded to this conversation, nothing uploaded at it yet, or a file bigger than its size or of another type
429 rate_limited More than 60 posts in a minute
502 storage_unavailable File storage didn't answer while checking an attachment; retry

#Endpoints

#GET /v1/messaging/conversations

Query: kind (master, job, dm, office, partner), unreadOnly=true, limit (1-200, default 50), before (the previous page's nextBefore). The company channel comes first, then conversations with messages, newest first; your office line (techs) and partner channels (office) are always listed.

json
{
  "conversations": [
    {
      "id": "7b1d0c52-0e44-4b61-9a3e-5d2f1c9e8a10",
      "kind": "job",
      "title": "Job #1042 · Repair visit",
      "subtitle": null,
      "jobId": 1042,
      "lastMessageAt": "2026-10-12T15:42:00.000Z",
      "lastMessage": { "id": 812, "preview": "Side gate code is 4411", "sender": "Dana Ruiz", "at": "2026-10-12T15:42:00.000Z" },
      "unread": 1,
      "mentions": 1,
      "muted": false,
      "canPost": true,
      "readOnly": null
    }
  ],
  "nextBefore": null,
  "unread": { "total": 3, "mentions": 1, "conversations": 2 }
}

canPost: false comes with readOnly, a sentence saying why.

#GET /v1/messaging/unread

{ "total": 3, "mentions": 1, "conversations": 2 }: unread messages across your conversations (a muted one adds only its mentions), unread mentions, and how many conversations have something unread.

#GET /v1/messaging/updates

Query: since (a cursor). Without it: { "cursor": 1931, "messages": [], "conversations": [], "unread": {...} }. With it: up to 200 changed messages, oldest first, plus conversations and unread; call again with the new cursor until messages is empty.

#GET /v1/messaging/search

Query: q (2-100 characters), limit (1-50, default 20). Messages containing q (case-insensitive) in conversations you read, newest first: { "results": [{ "message": {...}, "conversation": { "id", "kind", "title" } }] }.

#GET /v1/messaging/recipients

{ "people": [{ "userId", "name", "role", "personId" }] }: active people at your company you may start a direct message with, by the rules above. personId links a tech to the schedule.

#GET /v1/messaging/conversations/{id}

{ "conversation": {...}, "members": [{ "userId", "name", "role", "you", "company" }] }. company is set for people at the other CrewConnect company.

#GET /v1/messaging/conversations/{id}/messages

Query: limit (1-100, default 50), before (a message id: the page before it) or after (a message id: the messages after it). Oldest first. nextBefore is the id to pass as before for older messages, or null.

json
{
  "messages": [
    { "id": 805, "conversationId": "7b1d...", "kind": "system", "body": "Booked by the office for Mon Oct 12, 2:00 PM.", "deleted": false, "sender": null, "attachments": [], "mentions": [], "mentionsYou": false, "createdAt": "2026-10-12T15:30:00.000Z", "editedAt": null, "canEdit": false },
    { "id": 812, "conversationId": "7b1d...", "kind": "text", "body": "@Maria Gomez gate code is 4411", "deleted": false, "sender": { "userId": "1c0e...", "name": "Dana Ruiz", "you": false }, "attachments": [], "mentions": [{ "userId": "3f6a...", "name": "Maria Gomez" }], "mentionsYou": true, "createdAt": "2026-10-12T15:42:00.000Z", "editedAt": null, "canEdit": false }
  ],
  "nextBefore": null
}

#POST /v1/messaging/conversations/{id}/messages

See Posting. 201 with the message.

#POST /v1/messaging/conversations/{id}/read

Body { "messageId": 812 }, or {} for everything. Never moves backwards. Returns { "unread": {...} }.

#POST /v1/messaging/conversations/{id}/mute

Body { "muted": true }. Returns the conversation.

#POST /v1/messaging/conversations/{id}/attachments

See Attachments. 201 { "key", "uploadUrl", "expiresInSeconds": 900 }.

#PATCH /v1/messaging/messages/{id}

Body { "body": "..." }. Your own message, within 15 minutes. Returns the message with editedAt set.

#DELETE /v1/messaging/messages/{id}

Your own message, within 15 minutes. Returns it with deleted: true and an empty body.

#POST /v1/messaging/dms

Body { "userId": "..." }. Returns the conversation (the same one each time for the same two people). 403 techs_off for two techs when the owner hasn't allowed it; 404 for someone not at your company.

#GET /v1/messaging/jobs/{jobId}

A job your company does, or one you sent through CrewConnect. Returns the job's thread (made if it doesn't exist yet). A tech not on the job: 403.

#POST /v1/messaging/jobs/{jobId}/messages

Post in a job's thread without its conversation id. Same body and rules as Posting.

#GET /v1/messaging/office-line

A tech gets their own line to the office. The office passes techUserId to open a tech's line (404 if that isn't a tech at your company).

#POST /v1/messaging/office-line/messages

Techs only: post to your office line. The office answers with POST /v1/messaging/conversations/{id}/messages.

#Webhooks

message.posted fires for every person-written message outside direct messages. Subscribe to it like any other event (owner only).

#Known limitations

  • New messages arrive by polling; there are no push notifications to phones yet.
  • Search matches text anywhere in a message (no stemming or ranking).
  • Attachments are checked by type and declared size; the upload URL itself doesn't enforce the size.

CrewLink API · Reference · Changelog