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 |
curl -X POST $CREWLINK/v1/auth/start \
-H 'content-type: application/json' -d '{"email": "dana@northline.example"}'202
{ "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 |
curl -X POST $CREWLINK/v1/auth/verify \
-H 'content-type: application/json' \
-d '{"email": "dana@northline.example", "code": "482913"}'200
{
"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.
curl -X POST $CREWLINK/v1/auth/logout -H "authorization: Bearer $TOKEN"{ "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}#Invitation links
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.
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
{ "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.
{ "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.
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/meto 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.