CrewLink API

Sign-in

People (owners, field managers, dispatchers and techs) sign in with a 6-digit code sent to their email, or with a passkey they added on their device. There are no passwords. A successful sign-in returns a session token that works like any other bearer token for 30 days.

Use this for apps people sign in to: a custom dispatch screen, a tech's phone app, an internal tool. For server-to-server integrations use the company API key or a user token instead (see Authentication).

sequenceDiagram
  participant A as Your app
  participant C as CrewLink API
  participant M as Person's inbox
  A->>C: POST /v1/auth/start {email}
  C-->>A: 202 {sent: true}
  C-->>M: "123456 is your CrewLink code"
  A->>C: POST /v1/auth/verify {email, code}
  C-->>A: 200 {token, expiresAt, user, company}
  A->>C: any call with Authorization: Bearer <token>
  A->>C: POST /v1/auth/logout

#Lifetimes and limits

Thing Lifetime or limit
Sign-in code 6 digits, works for 10 minutes, once. An invite carries one too
Tries per code 5 wrong tries, then ask for a new code
Codes per person 5 an hour
Wrong codes per person After 10 wrong codes in 24 hours, code sign-in stops for that person until the oldest of those failures is a day old
Session token 30 days from sign-in (expiresAt), or until logout or the user is turned off
User token Until the owner issues a new one or turns the user off
Company API key Until CrewLink rotates it for you
Requests per IP POST /v1/auth/start: 5 a minute. POST /v1/auth/verify: 10 a minute. Passkey sign-in: see Passkeys

Sessions don't slide: a session expires 30 days after sign-in however much it's used. When a call answers 401, send the person back through sign-in.

#POST /v1/auth/start

Emails a sign-in code. No key needed.

Body Type Required Notes
email string yes Up to 320 characters; case doesn't matter
bash
curl -X POST $CREWLINK/v1/auth/start \
  -H 'content-type: application/json' -d '{"email": "dana@northline.example"}'

202

json
{ "sent": true }

The answer is always 202, whether or not the email belongs to anyone, and it comes back before the account is even looked up, so neither the answer nor its timing reveals who has an account. If the email belongs to people at several companies, each gets a code naming the company.

Errors: 422 invalid_request (not an email), 429 rate_limited (more than 5 a minute from this IP; wait for Retry-After).

#POST /v1/auth/verify

Trades the code for a session.

Body Type Required Notes
email string yes The same email
code string yes Exactly 6 digits
companyId string (UUID) no When the email belongs to people at more than one company, which company to sign in to
bash
curl -X POST $CREWLINK/v1/auth/verify \
  -H 'content-type: application/json' \
  -d '{"email": "dana@northline.example", "code": "482913"}'

200

json
{
  "token": "9b2f6c1e-7a4d-4e3b-8f20-5c6d7e8f9a0b.<secret>",
  "expiresAt": "2026-11-11T15:30:00.000Z",
  "user": { "id": "3c1d2e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "name": "Dana Ruiz", "role": "dispatcher", "personId": null },
  "company": { "id": "7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b", "name": "Northline Heating & Air", "slug": "northline" },
  "companies": [
    { "id": "7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b", "name": "Northline Heating & Air", "slug": "northline", "role": "dispatcher", "lastUsedAt": "2026-10-12T15:30:00.000Z" }
  ]
}
Field Notes
token Send as Authorization: Bearer <token>. Store it in the device's secure storage
expiresAt When the session ends
user.role owner, dispatcher or tech: which endpoints this session can call
user.personId For techs, their id on the schedule (the field app acts as this person); null for office users who aren't on the schedule
companies Every company this email can sign in at (an active user at each), the one used most recently first: id, name, slug, role, lastUsedAt (or null). More than one: ask which, and switch if they pick another. Passkey sign-in, welcome links and invitation links list only the company signed in to (they don't prove the email)
Status error When
401 bad_code Wrong, used or expired code, too many tries, or the person is locked out. The same answer in every case, so it reveals nothing
422 invalid_request Not an email, or the code isn't 6 digits
429 rate_limited More than 10 a minute from this IP

A person with accounts at several companies gets one code per company; without companyId they are signed in to the company whose code they entered. Pass companyId to make the choice explicit. The answer lists all of them in companies; the web app asks "Which company?" when there's more than one.

#GET /v1/auth/companies

The companies the signed-in person's email can sign in at, the one used most recently first, and which one this sign-in is: { "current": "<company id>", "companies": [ ... ] } (as companies above). Only a session that signed in with an emailed code (verify), or switched from one, lists the other companies; any other sign-in (a user token, a passkey, a welcome link or an invitation) lists this company only, because those prove the account, not the email address. 403 for a company API key or an act-as session (no person of their own); 401 without a sign-in (a platform key isn't one).

#POST /v1/auth/switch

Move to the same person's sign-in at another company: a new 30-day session as their user at companyId, only where the same email has an active user. Answers like verify. Needs a session that signed in with an emailed code, or switched from one: anything else is 403 email_unproven ("Sign in with the code we email you to switch companies."), because an email typed in at sign-up or by an owner proves nothing about who reads it. A session it was called with ends (one sign-in at a time per browser); a sign-in token keeps working. The web app's company switcher (top of the sidebar) uses it.

Name Type Required Notes
companyId string (UUID) yes One of the companies
Status error When
401 unauthorized No sign-in
403 forbidden A company API key or an act-as session
404 not_found That email has no active user at that company
429 rate_limited More than 10 a minute from this IP

#POST /v1/auth/logout

Ends the session in the Authorization header. Always answers 200, so it's safe to call on a session that has already expired. It is also how CrewLink staff end an act-as session early (admin.md); that's written to the admin audit log.

bash
curl -X POST $CREWLINK/v1/auth/logout -H "authorization: Bearer $TOKEN"
json
{ "ok": true }

Only sessions are ended this way. A user token keeps working until the owner issues a new one or turns the user off.

#Invites

An owner (or a dispatcher, for techs) can email someone an invite: POST /v1/office/users/{id}/invite, or POST /v1/office/users with their email. The invite carries a sign-in code that was started for them exactly as POST /v1/auth/start would, so it follows the same rules (10 minutes, once, 5 tries, 5 codes an hour), and a link to the CrewLink web app that opens on the code step for their email. They enter the code and are signed in: no separate "email me a code" step.

If you build your own sign-in screen, an invited person can also use the invite's code with POST /v1/auth/verify as usual. When the code has run out, call POST /v1/auth/start for a new one.

sequenceDiagram
  participant O as Owner
  participant C as CrewLink API
  participant M as Person's inbox
  participant W as CrewLink web app
  O->>C: POST /v1/office/users/{id}/invite
  C-->>O: 200 {inviteSentAt}
  C-->>M: "Olive added you to Northline on CrewLink" + link + 482913
  M->>W: open the link (code step, email filled in)
  W->>C: POST /v1/auth/verify {email, code}
  C-->>W: 200 {token, expiresAt, user, company}

An invitation can also be a one-time link, https://go.crewlink.ai/join/<token>, made with POST /v1/office/invitations (and emailed when CrewLink sends email). The link itself is the invitation: it works once, for 7 days, and accepting it creates the person's sign-in and signs them in. No code is needed for that first sign-in; afterwards they add a passkey or sign in with an emailed code as usual.

The CrewLink web app handles /join/<token> for you. If you build your own page, use these two calls (no key needed; the token is the credential, so never log it):

#GET /v1/public/invitations/{token}

What the link is for: { "name", "email", "role", "company", "provider", "invitedBy", "expiresAt" }. provider is set when they join a client company (the company it files requests with). 404 not_found for an unknown link; 410 with invitation_used, invitation_revoked or invitation_expired.

#POST /v1/public/invitations/{token}/accept

Body (optional): { "name": "Dana One" } to correct the name. Creates the user with the invited role, team and contact details and answers like POST /v1/auth/verify: { token, expiresAt, user, company }, a 30-day session. Then offer a passkey (POST /v1/auth/passkeys/options). 409 already_member when that email already signs in at that company; 404 / 410 as above. Two clicks at once make one user: the second gets 410 invitation_used. 10 calls a minute per IP.

sequenceDiagram
  participant O as Owner
  participant C as CrewLink API
  participant P as Invited person
  O->>C: POST /v1/office/invitations {role, name, email}
  C-->>O: 201 {link: /join/<token>}
  O-->>P: the link (and CrewLink emails it)
  P->>C: GET /v1/public/invitations/<token>
  P->>C: POST /v1/public/invitations/<token>/accept
  C-->>P: 200 {token, expiresAt, user, company}
  P->>C: POST /v1/auth/passkeys/options, POST /v1/auth/passkeys

#Passkeys

A passkey lets a person sign in with their device's fingerprint, face or screen lock instead of an emailed code. They add one while signed in, then use it from the sign-in screen. Passkey sign-in opens the same 30-day session as a code and returns the same body as POST /v1/auth/verify.

Passkeys follow the WebAuthn standard. The examples use @simplewebauthn/browser (startRegistration, startAuthentication); the browser's own PublicKeyCredential.parseCreationOptionsFromJSON(), parseRequestOptionsFromJSON() and toJSON() work too. Send the credential exactly as the browser produced it.

A passkey works only on the site it was made on: the CrewLink web app (the API's PUBLIC_URL). The API refuses answers made on any other origin, so a look-alike site can't use one. That also means passkeys work only in the CrewLink web app for now: your own app or site signs people in with the emailed code (native apps would need CrewLink's domain association, which isn't set up yet).

sequenceDiagram
  participant A as Your app (browser)
  participant C as CrewLink API
  participant D as Device
  Note over A,C: Adding one (signed in)
  A->>C: POST /v1/auth/passkeys/options
  C-->>A: {options}
  A->>D: startRegistration(options)
  D-->>A: credential
  A->>C: POST /v1/auth/passkeys {response}
  C-->>A: 201 {passkey}
  Note over A,C: Signing in (no token)
  A->>C: POST /v1/auth/passkeys/login/options
  C-->>A: {loginId, options}
  A->>D: startAuthentication(options)
  D-->>A: assertion
  A->>C: POST /v1/auth/passkeys/login {loginId, response}
  C-->>A: 200 {token, expiresAt, user, company}
Thing Lifetime or limit
Options (registration or sign-in) Answer within 5 minutes, once. After that, or after any answer, start again
Passkeys per person 20
Requests per IP POST /v1/auth/passkeys/login/options and POST /v1/auth/passkeys/login: 10 a minute each

Passkeys belong to a person (a user), so adding, listing and removing them needs a session or user token; the company API key gets 403 needs_user. Any role can have passkeys. Turning a user off or issuing them a new token (Office: users) removes all their passkeys.

#POST /v1/auth/passkeys/options

Starts adding a passkey for the signed-in user. No body.

js
const { options } = await post('/v1/auth/passkeys/options');
const response = await startRegistration({ optionsJSON: options });
const { passkey } = await post('/v1/auth/passkeys', { response });

200 { "options": { ... } }, the WebAuthn creation options: relying party CrewLink on the app's hostname, the user id as the user handle, the person's email as the account name and "name · company" as the display name, their existing passkeys in excludeCredentials (so a device doesn't add a second one), resident key and user verification preferred, attestation none, timeout 5 minutes.

Errors: 401 unauthorized, 403 needs_user (company API key).

#POST /v1/auth/passkeys

Saves the passkey the device just made.

Body Type Required Notes
response object yes The registration credential from the browser (id, rawId, type, response.clientDataJSON, response.attestationObject, response.transports, ...)
name string no Up to 60 characters. Default: the browser and device it was added on, like Chrome on Mac

201

json
{ "passkey": { "id": "5b0e8c2a-3f1d-4a7e-9c6b-2d8f0a1e4c3b", "name": "Chrome on Mac", "transports": ["internal", "hybrid"], "createdAt": "2026-10-12T15:30:00.000Z", "lastUsedAt": null } }
Status error When
400 challenge_expired No options outstanding for this user, they were already answered, or they're older than 5 minutes. Start again
400 bad_passkey The credential didn't verify (wrong site, wrong challenge, malformed)
409 already_added That credential is already saved, for this or another user
409 too_many The person has 20 passkeys. Remove one first

#GET /v1/auth/passkeys

The signed-in user's own passkeys, oldest first.

json
{ "passkeys": [{ "id": "5b0e8c2a-3f1d-4a7e-9c6b-2d8f0a1e4c3b", "name": "Chrome on Mac", "transports": ["internal", "hybrid"], "createdAt": "2026-10-12T15:30:00.000Z", "lastUsedAt": "2026-10-14T13:02:11.000Z" }] }

#DELETE /v1/auth/passkeys/{id}

Removes one of your passkeys. It stops working for sign-in at once (the copy on the device stays until the person deletes it there). Sessions it opened keep going until they end or the person logs out.

200 { "removed": true }. 404 not_found for anything that isn't yours, including another user's passkey.

#POST /v1/auth/passkeys/login/options

Starts a passkey sign-in. No key needed.

Body Type Required Notes
email string no Narrows the sign-in: the passkey must then belong to a user with this email. Never looked up here

Send {} for no email.

js
const { loginId, options } = await post('/v1/auth/passkeys/login/options', {});
const response = await startAuthentication({ optionsJSON: options });
const session = await post('/v1/auth/passkeys/login', { loginId, response });

200 { "loginId": "...", "options": { ... } }: request options for a discoverable passkey (no allowCredentials, so the device offers the CrewLink passkeys it has), user verification preferred, timeout 5 minutes. The answer is the same whoever asks and whatever the email, so it reveals nothing about who has a passkey.

Errors: 429 rate_limited (more than 10 a minute from this IP).

#POST /v1/auth/passkeys/login

Trades the device's answer for a session.

Body Type Required Notes
loginId string yes From POST /v1/auth/passkeys/login/options. Works once
response object yes The authentication credential from the browser (id, rawId, type, response.clientDataJSON, response.authenticatorData, response.signature, response.userHandle)

200: the same body as POST /v1/auth/verify (token, expiresAt, user, company). The passkey's user and company are signed in; a person with accounts at several companies has a separate passkey for each and picks one in the device's account list.

The API checks the origin and site, the challenge, the signature against the saved public key, the user handle when the device sends one, and the signature counter for authenticators that keep one: a counter that doesn't go up means the key was copied, and is refused.

Status error When
401 challenge_expired Unknown loginId, already used, or older than 5 minutes. Start again
401 bad_passkey Unknown or removed passkey, user turned off, email didn't match, wrong site, bad signature or counter. The same answer in every case
422 invalid_request The body isn't shaped like a credential
429 rate_limited More than 10 a minute from this IP

#After sign-in

  • Office users (owner, dispatcher) can call GET /v1/office/me to load the company's name, time zone and settings and their own role.
  • Techs use the field app endpoints, which act as user.personId.
  • Signing in as someone requires that the owner has added them as a user with that email (Office: users); adding them with an email also emails them an invite. Turning a user off ends their sign-in, their user token and every session at once, and removes their passkeys. Issuing a new user token doesn't end existing sessions, and logout only ends the session it's called with, so if a signed-in device is lost, turn the user off and add them again.

CrewLink API · Reference · Changelog