DoneWell Public REST API v1
A JSON REST API for reading and writing your company's DoneWell data — customers, requests, quotes, jobs, invoices, tasks (with their kanban boards), payments and wiki pages.
Overview
- Base URL:
/api/v1— in productionhttps://donewellapp.com/api/v1. - Authentication: send your key as a Bearer token on every request:
The same header also accepts a user access token — the DoneWell mobile app's credential, which acts with the signed-in user's role instead of a key's scopes. See User tokens.Authorization: Bearer dw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - Keys are created in the app under Settings → Developer → API Keys. Each key is scoped per resource × action (see Scopes). The full key is shown only once at creation — copy and store it securely; it cannot be retrieved again. Keys can be revoked at any time and can optionally be given an expiry.
- Availability: the API is part of the Business plan. Companies on other plans can be
granted access by DoneWell support (a per-company override). A key that belongs to a company
without API access returns
403 module_disabled. - Content type: all request bodies are JSON. Send
Content-Type: application/json. Unknown body fields are rejected (422 validation_failed). The four uploads — task attachments, visit photos, visit notes and expense receipts — takemultipart/form-datainstead (POST /expensesaccepts either).
Conventions
Response envelopes
A single resource:
{ "data": { "id": "clx...", "firstName": "Ada", ... } }
A list (cursor-paginated):
{
"data": [ { ... }, { ... } ],
"pagination": { "nextCursor": "clx...", "hasMore": true }
}
hasMore is derived server-side (nextCursor !== null); never infer it from the page size.
Pagination
List endpoints use opaque cursor pagination:
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
int | 25 |
Clamped to 1–100. Non-numeric falls back to 25. |
cursor |
string | — | Pass the previous response's pagination.nextCursor. |
Results are ordered by internal id descending (newest first, stable). To page: request without a
cursor, then keep passing nextCursor until hasMore is false.
Errors
Every error uses the same envelope:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "firstName": ["Invalid input: expected string, received undefined"] } } }
details maps each TOP-LEVEL field to zod's messages for it ({ field: string[] } — the shape is pinned by
API-056…060). The strings are zod 4's default wording where a schema sets none ("Invalid input: expected
string, received undefined" for a missing required key, since zod 4 / T-418; 3.x said "Required") — key on
code and the field names, never on the sentence.
code is a stable machine-readable string; message is human-readable; details is present only
on validation errors (a map of field → messages).
| HTTP | code |
When |
|---|---|---|
| 400 | invalid_json |
Request body is not valid JSON. |
| 401 | invalid_key |
Missing/malformed Authorization, or unknown/revoked/expired key. |
| 401 | invalid_token |
A user access token whose signature does not verify or whose exp has passed. |
| 401 | token_revoked |
A user access token whose user changed their password (tokenVersion), was deactivated, or is no longer a member of the company it names — or a refresh token of a session that was signed out. Get a new token. |
| 401 | invalid_credentials |
Mobile login: wrong password or unknown email (one code — the API never says which). |
| 401 | two_factor_required |
Mobile login: the account has 2FA and totp was missing or wrong (one code, for the same reason). |
| 401 | refresh_reused |
Mobile refresh: this refresh token was already exchanged — a replay. Every session of that device is revoked; sign in again. |
| 403 | account_deactivated |
Mobile login: the account (or its every membership) is deactivated. Revealed only after the right password. |
| 403 | email_not_verified |
Mobile login: the email address has not been verified yet. |
| 403 | no_company |
Mobile login/refresh: the user has no company to act in — create one on the web first. |
| 403 | insufficient_scope |
The key lacks the required resource:action scope — or, for a user token, the role lacks the permission (the message names it). |
| 403 | prices_hidden |
A user token whose role cannot see prices (pricing.view off) sent a field that writes money — lineItems, discountType/discountValue, taxRateId, taxInclusive on a quote/job/invoice. Nothing is written. See User tokens. |
| 403 | not_assigned |
A user token whose role does not manage everyone's visits (allVisits.edit off) wrote to a visit whose crew it is not on — start, complete, reopen, PATCH, line-items, DELETE. Nothing is written. A company key is never crew-limited. |
| 403 | account_suspended |
The company has been suspended by the DoneWell platform. |
| 403 | module_disabled |
API access is not enabled for the company's plan. |
| 403 | plan_limit_reached |
A create would exceed the plan's entity limit (customers/quotes/etc.). |
| 404 | not_found |
The resource doesn't exist, or belongs to another company. |
| 409 | conflict |
Duplicate invoice number, a delete blocked by related records, a task number that could not be allocated (retry the same request), or a wiki page whose body changed since your expectedUpdatedAt / archived. |
| 409 | confirmation_required |
The save would delete or re-price something the app asks about first; nothing was written. error.details holds ONE key — the body flag to re-send as true (job blocks: confirmVisitRemoval, confirmPerVisitExit). |
| 409 | duplicate |
A board label with that name already exists on the board. |
| 409 | duplicate_candidates |
POST /tasks found open cards that look like this one (title, or title + description) — see Duplicate guard. Repeating the request unchanged returns the same 409. |
| 413 | payload_too_large |
The JSON body is larger than 1 MB (wiki pages: 2 MB). Checked against Content-Length before anything is read, and against the bytes actually received (a chunked body is cut off at the limit). File uploads (multipart / urlencoded) are capped too, per route: number of files × 10 MB + 1 MB for fields — task attachments and visit photos 101 MB (10 files), task comments 61 MB (6 files), expenses 51 MB (5 receipts); same two checks, same error. |
| 422 | validation_failed |
Body failed schema validation, or a referenced id isn't yours. |
| 429 | rate_limited |
Rate limit exceeded — see Retry-After header (seconds). |
| 500 | internal_error |
Unexpected server error. |
Revoked and expired keys return the same 401 invalid_key as an unknown key — the API never
reveals whether a key once existed. A cross-tenant id always returns 404 not_found, never 403.
Rate limits
120 requests per minute per key. Exceeding it returns
429 rate_limitedwith aRetry-Afterheader (seconds to wait). A user token has the same cap per user × device.Invalid-key throttling: 20 failed auth attempts per IP per 15 minutes also return
429(brute-force / key-enumeration guard). Only failures count — an unknown key and a user token that fails to verify count the same.Mobile login: 6 attempts per IP × email per minute, plus the web login's 30 per IP per 15 minutes. Both count attempts, not failures — the 7th try in a minute is
429whether the password is right or not.
Rate limits are enforced per app instance (in-memory).
Dates
All timestamps are ISO 8601 UTC (e.g. 2026-07-25T14:30:00.000Z). Date-time inputs accept a
bare/Z UTC timestamp or an explicit offset (2026-07-25T10:30:00-04:00). Task dueDate is a
calendar date, YYYY-MM-DD; task dueTime is HH:mm (24h).
Scopes
A key grants a set of resource:action scopes. Actions are view, create, edit, delete.
payments is read-only for a key — only payments:view can be granted (a USER token whose role holds payments.edit can record a payment: POST /payments).
| Resource | Actions available | User-token permission |
|---|---|---|
clients |
view, create, edit, delete | clients |
requests |
view, create, edit, delete | requests |
quotes |
view, create, edit, delete | quotes |
jobs |
view, create, edit, delete | jobs |
invoices |
view, create, edit, delete | invoices |
tasks |
view, create, edit, delete | tasks |
payments |
view | payments |
visits |
view, create, edit, delete | schedule |
inbox |
view, create, edit, delete | conversations |
wallets |
view, create, edit, delete | wallets |
expenses |
view, create, edit, delete | expenses |
public |
view | marketing |
wiki |
view, create, edit, delete | wiki |
catalog |
view | products (see below) |
One name differs between the product and the API: what the app calls Clients is the
/api/v1/customers resource, guarded by the clients:* scopes. There is no /api/v1/clients path.
A call missing the needed scope returns 403 insufficient_scope. visits has its read endpoints
(see Visits, plus the Team and Crews pickers) and the technician's three writes, inbox its five (see Inbox),
wallets its list (see Wallets), expenses the phone's booking, its recent list and
the category picker (see Expenses — the booking takes user tokens only, a key can
read the list and the categories),
public — the company's public surfaces as the phone reads them — the two QR feeds (see
Public links), and catalog — the line-item and tax pickers (see Catalog, Tax rates),
read-only like payments. Two rows of the third column need a footnote the table cannot
spell: the visit writes (start / complete / reopen / PATCH / notes / photos / line items) take
jobs:edit or schedule:edit on a user token, exactly the pair the web's Start/Finish buttons
accept — the 403 names both; catalog takes products:view or create/edit on any of quotes,
jobs and invoices or schedule:edit (whoever can put a line on a document can open the
picker); /search and /recent open for a caller holding view on ANY of clients, requests,
quotes, jobs, invoices and answer only the types it holds (see Search); and
GET /review-links asks a user token for no permission at all for the person's OWN links
(marketing:view only unlocks the company pages beside them), mirroring the web's self-scoped
"My review links" page. The third column is what a user token needs instead of a scope — the
role permission the app itself checks for that area.
The tasks scope covers both the cards (/tasks) and the settings of the boards they sit on
(/boards — columns, labels, custom-field definitions). There is no separate boards scope: a key
that may edit cards may also edit the columns those cards live in. Grant tasks:view only if you
want a read-only integration.
tasks:deleteis a separate tick, and a key set up for "an agent that works the board" often does not have it. Deleting a column, a label or a custom-field definition is scoped to it — not totasks:edit— so a key granted view/create/edit answers403 insufficient_scopethere while every other setup call succeeds, which reads as those endpoints being broken. Fix it where the key lives: Settings → API keys. (Deleting a card ATTACHMENT istasks:edit, mirroring the app.)
Trust model
An API key acts with company-level trust, not on behalf of the user who created it:
- Scopes are independent of the creating user's role — a user who can manage API keys can mint a key with scopes broader than their own role permissions. Treat key creation as an administrator-level capability.
- A key with the
tasksscope can read and edit every task in the company — and every board's settings — including private task boards the key's creator cannot see in the app. Per-board visibility applies to people — app sessions and user tokens — never to a key. - The same holds for the
wikiscope: a key reads every wiki page, including private pages and sections (T-796) that the key's creator cannot see in the app. - Grant each integration the narrowest scope set it needs, and revoke keys that are no longer used.
A user token is the other principal: it acts with user-level trust — the role of the
signed-in member, re-read from the database on every request, in the one company the token names.
Both principals are scoped to companyId, and a cross-tenant id is 404 not_found for both. The
two places they differ are private task boards and private wiki pages: a user token sees
exactly the boards and pages the app shows that member (see User tokens), a key
sees them all.
User tokens
The DoneWell mobile app signs in as a person, not as an integration, so it carries a short-lived
access token in the same Authorization: Bearer slot:
- Format: a compact JWS (
HS256), claimsuserId,companyId,deviceId,tokenVersion,iat,exp. Access tokens live 15 minutes; the app renews them with its refresh token (POST /api/v1/auth/mobile/refresh— see Mobile login). Tokens are not something an integration can mint: only the mobile login/refresh endpoints issue them. - Authorization = the role: a request needs the permission the app itself checks for that
area (the third column of the Scopes table) —
jobs:edittoPATCH /jobs/:id,schedule:viewto list visits. A missing permission is403 insufficient_scopeand the message names the permission. Roles are read on every request, so an edit to a role applies at once. - Revocation: a password change, a deactivated account, or leaving the company answers
401 token_revokedon the very next request — the token'stokenVersionis compared with the user's, the same signal that ends the user's web sessions.401 invalid_tokenis a token that does not verify or has expired. Either way: refresh, or sign in again. - No
apimodule: the Public API plan module gates company keys (integrations). A member on the mobile app is the same person the web app lets in, so a user token works on every plan. - Attribution: writes made with a user token are attributed to the user — a task comment
lands with
authorIdset and the person's name, activity entries name them — where a key leavesauthorId: nulland its label. - Rate limit: 120 requests per minute per user × device.
- Prices & amounts (
pricing.view, Settings → Roles): for a role without it every money field of a request/quote/job/invoice comes backnull—subtotal,taxAmount,minimumCharge,total,discountValue,promoDiscountAmount,fees[].amount,amountPaid,balanceDueand each line'sunitPrice/total— while the rest of the document (lines with their names and quantities, statuses, dates) is unchanged. A create/patch carrying a price field (lineItems— on create only when non-empty —,discountType,discountValue,taxRateId,taxInclusive) is refused whole with403 prices_hidden; the same call without them works. Adw_live_key has no role, so this never applies to it. - Private boards: the
tasksandboardsroutes apply the app's board visibility on top of the role — a private board is visible to its creator, its members, members of a granted role and company admins (admin:view), exactly as in the app. A board the member cannot open answers the way a non-existent one does: absent fromGET /boards,404on/boards/:id/**, on a card of it and on that card's comments/activity/attachments,[]forGET /tasks?boardId=/?columnId=,422 Column not foundas a move target,404as aparentIdon create, and never a duplicate candidate. Calendar-only cards (no board) are not gated.
Mobile login
The endpoints that issue and retire user tokens. None takes a Bearer — they are where one comes
from. Every request body is JSON, .strict() like the rest of the API.
POST /api/v1/auth/mobile/login — { email, password, deviceId, deviceName?, totp? }
deviceIdis a stable id the app mints at first install (≤128 chars);deviceNameis a label for a future devices list.totpis the 6-digit code or a recovery code when the account has 2FA.The decision is the web login's own, in the same order: password → account status → second factor → company. A wrong password and an unknown email are one
401 invalid_credentials; a deactivated account is403 account_deactivatedonly after the right password; a missing and a wrongtotpare one401 two_factor_required(the app shows the code field and retries); an unverified email is403 email_not_verified; a person with no company is403 no_company. Sign-in with Google has its own endpoint below.Answer:
{ "data": { "tokenType": "Bearer", "accessToken": "eyJ…", "expiresIn": 900, "refreshToken": "dw_refresh_…", "refreshExpiresAt": "2026-10-21T05:00:00.000Z", "companyId": "clx…", "companies": [ { "id": "clx…", "name": "Acme Cleaning", "roleName": "Technician" } ], "user": { "id": "clx…", "email": "ada@acme.test", "firstName": "Ada", "lastName": "Field" }, "locale": { "timezone": "America/Toronto", "dateFormat": "MM/DD/YYYY", "timeFormat": "12h", "currency": "CAD" }, "capabilities": { "modules": { "portal": false, "inbox": true, "tasks": true, "…": "every module key" }, "permissions": ["dashboard.view", "schedule.view", "jobs.view", "jobs.edit", "conversations.view"], "outboundMode": { "mode": "CALLBACK", "ready": true, "refusal": null, "refusalCode": null }, "callbackNumberSet": true } } }companyIdis the company the access token acts in —activeCompanyIdwhen it is still an active membership, else the first one — listed first incompanies. To act in another company of the list, seeswitch-companybelow.localeis that company's localization (Settings → Company): the IANAtimezoneits "today" and instants live in,dateFormat(MM/DD/YYYY·DD/MM/YYYY·YYYY-MM-DD),timeFormat(12h·24h) andcurrency(CAD·USD·EUR·GBP). Always present and always one of those values — a stored value outside them is answered as its default. Every body that carries a session carries it (login,google,refresh,switch-company), so a settings change reaches the app on its next refresh and a switch answers the NEW company's.capabilitiesis what the acting company's plan and the person's role there allow, so the app hides a tab or a button instead of drawing one that answers403 module_disabled/insufficient_scope.modulescarries every plan-module key (true/false) as the server resolves it — plan defaults → HQ plan config → workspace vertical → per-company overrides, the same resolution every module gate runs (see modules/INDEX.md).permissionslists only the granted rights of the person's role in that company as"resource.action"strings ("conversations.view","jobs.edit"), in the role editor's order; a right not listed is not held.outboundModeis how this person's in-app Call button connects at that company —modeCALLBACK(their phone rings first) orBROWSER(the web softphone), the company default unless they chose their own under Settings → Calls — andreadywhether that mode could place a call right now; when it cannot,refusalis the sentence naming the fix andrefusalCodethe same codePOST /api/v1/calls/startanswers as a409(softphone_not_enabled,callback_number_required,calling_unavailable,outbound_calling_disabled,no_voice_line,module_disabled) — bothnullwhen ready. The Call button usesPOST /api/v1/calls/start, which follows this mode;POST /api/v1/callsis always call-back.callbackNumberSetis whether THIS person has a call-back number (Settings → My calling) —falsemeansPOST /api/v1/callswill refusecallback_number_required, so the app can say so before asking to confirm the call. The number itself is never in the body. Advisory only — every endpoint still checks both itself. Carried by every session body (login,google,refresh,switch-company,GET /mobile/session).A login on a device that already holds a session replaces it: one device, one live session.
POST /api/v1/auth/mobile/google — { idToken, deviceId, deviceName? }
- Google Sign-In from the app: the phone's Google SDK returns an ID token; the server verifies
it itself (signature against Google's published keys, issuer, expiry) and requires its audience
to be one of the mobile OAuth client ids (
GOOGLE_MOBILE_CLIENT_IDS, iOS and Android). A token minted for the web client (GOOGLE_CLIENT_ID) is refused —401 invalid_token, the same code as a bad signature or an expired token. When no mobile client id is configured the endpoint answers503 google_signin_disabled. - The account decision is the web's Google sign-in decision, with one difference: no
registration. The Google address must belong to an existing DoneWell account, else
403 no_account(sign up on the web first). Then, as on the web:403 unverified_emailwhen Google has not verified the address;403 email_not_verifiedwhen the DoneWell account itself never verified its email (a Google attestation does not vouch for a mailbox someone else registered);403 account_deactivated;403 no_company. Nototp: a Google sign-in delegates the second factor to Google, exactly as the web login does. - Answer: the login body above (
accessToken,refreshToken,companies, …). The refresh token is a normal one —refreshandlogoutwork the same.
POST /api/v1/auth/mobile/switch-company — { companyId } — user token required
A new access token acting in another company of the caller's
companieslist — a technician with two companies switches without signing in again. The answer is the login body withoutrefreshToken/refreshExpiresAt: the device's refresh token is not reissued and keeps working, so the app keeps the one it has and replaces only the access token.companiesis re-ordered with the new company first.The switch is remembered: it writes the person's
activeCompanyId(what the web company switcher writes), so the nextrefreshstays in the new company — and the person's web session follows on its next refresh, as it does when they switch in another browser tab.403 not_a_member: a company the person is not an active member of.403 user_token_requiredfor a company API key. Allowed even when the token's current company is suspended — switching out is the way out.{ "data": { "tokenType": "Bearer", "accessToken": "eyJ…", "expiresIn": 900, "companyId": "cly…", "companies": [ { "id": "cly…", "name": "Beta Cleaning", "roleName": "Technician" }, { "id": "clx…", "name": "Acme Cleaning", "roleName": "Technician" } ], "user": { "id": "clx…", "email": "ada@acme.test", "firstName": "Ada", "lastName": "Field" }, "locale": { "timezone": "Europe/London", "dateFormat": "DD/MM/YYYY", "timeFormat": "24h", "currency": "GBP" }, "capabilities": { "modules": { "inbox": false, "…": "every module key" }, "permissions": ["schedule.view", "jobs.view"] } } }
POST /api/v1/auth/mobile/refresh — { refreshToken }
- Exchanges the refresh token for a new access + refresh pair (the same shape as login). The presented token is retired: the next refresh must use the new one. Refresh tokens live 30 days, sliding — every refresh extends the session, so a device used at least monthly stays signed in.
- Presenting a refresh token that was already exchanged is treated as a replay:
401 refresh_reused, and every token of that device's session — the replayed one and its successor alike — is revoked. The app signs in again. 401 token_revoked: the session was signed out (logout, a replay on this device, a password change, deactivation, leaving the company).401 invalid_token: unknown or expired. The user's status andtokenVersionare re-checked on every refresh, so a password change ends every device's session at its next refresh (and the access token in hand at its next request).
POST /api/v1/auth/mobile/logout — { refreshToken }
- Ends the session of that device only; the user's other devices keep working. Takes the
refresh token because the access token is usually expired by the time someone signs out. Always
200 { "data": { "ok": true } }— idempotent, and an unknown token is not distinguished. - Also stamps that device's push registration
disabledAt(T-597) — the app's ownDELETE /mobile/devices/:idis best-effort and does not run on a sign-out without signal.
GET /api/v1/mobile/session — user token required
The session as it stands, without issuing anything:
companyId,companies,user,localeandcapabilitiesfor the company the access token acts in — the login body minustokenType/accessToken/expiresIn/refreshToken/refreshExpiresAt. Cheap enough to call on every app resume, and the refresh token is not rotated.401 invalid_token(bad or expired token),401 token_revoked(membership ended, account deactivated, password changed) — an error body carries nocapabilities.403 user_token_requiredfor a company API key;403 account_suspendedwhen the company is suspended (switch company to get out, as for every other user-token endpoint).{ "data": { "companyId": "clx…", "companies": [ { "id": "clx…", "name": "Acme Cleaning", "roleName": "Technician" } ], "user": { "id": "clx…", "email": "ada@acme.test", "firstName": "Ada", "lastName": "Field" }, "locale": { "timezone": "America/Toronto", "dateFormat": "MM/DD/YYYY", "timeFormat": "12h", "currency": "CAD" }, "capabilities": { "modules": { "inbox": false, "…": "every module key" }, "permissions": ["schedule.view", "jobs.view"] } } }
GET /api/v1/mobile/version — no auth
{ "data": { "minSupportedVersion": "1.2.0", "latestVersion": "1.4.1" } }
The forced-upgrade floor and the newest published build, from the server's MIN_MOBILE_VERSION /
LATEST_MOBILE_VERSION (unset → 1.0.0, i.e. nothing refused). The app asks on cold start and
refuses to run below the floor. Cached for five minutes.
Customers
The Clients of the app (Clients page, client records). The path is /customers — there is no
/api/v1/clients — while the scope is clients:*, the same name the app's roles use.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/customers |
clients:view |
| POST | /api/v1/customers |
clients:create |
| GET | /api/v1/customers/:id |
clients:view |
| PATCH | /api/v1/customers/:id |
clients:edit |
| DELETE | /api/v1/customers/:id |
clients:delete |
| PATCH | /api/v1/customers/:id/blocks/:block |
clients:edit — see Block edits |
| POST | /api/v1/customers/:id/properties |
clients:edit |
| PATCH | /api/v1/customers/:id/properties/:propertyId |
clients:edit |
| DELETE | /api/v1/customers/:id/properties/:propertyId |
clients:edit |
| GET | /api/v1/tags |
clients:view — the tag picker, see below |
List filters (query params, in addition to limit/cursor):
status— one ofLEAD,ACTIVE,INACTIVE.search— case-insensitive substring over first name, last name, company name.
Create / patch body
| Field | Type | Create | Notes |
|---|---|---|---|
firstName |
string | required | Optional on PATCH. |
lastName |
string | null | optional | Defaults to "". |
title |
string | null | optional | |
companyName |
string | null | optional | |
status |
enum CustomerStatus |
optional | LEAD / ACTIVE / INACTIVE. |
source |
enum CustomerSource |
optional | WEBSITE / YELP / GOOGLE / REFERRAL / PHONE / OTHER. |
notes |
string | null | optional | |
leadSourceId |
id | null | optional | Must belong to your company. |
emailOptIn |
boolean | optional | |
smsOptIn |
boolean | optional | |
billingAddressSource |
string | null | optional | e.g. manual, property_0. |
billingAddressLine1 |
string | null | optional | |
billingAddressLine2 |
string | null | optional | |
billingCity |
string | null | optional | |
billingState |
string | null | optional | |
billingZipCode |
string | null | optional | |
billingCountry |
string | null | optional | |
phones |
array of phone (max 10) | optional | See below. |
emails |
array of email (max 10) | optional | See below. |
properties |
array of property (max 20) | optional | Create only. |
Phone entry: { phone (string, min 5), label?, isPrimary? }. Email entry:
{ email (valid email), label?, isPrimary? }. Property entry:
{ addressLine1 (required), addressLine2?, city?, state?, zipCode?, country?, taxRateId? } — an omitted taxRateId takes the company's default tax rate (as the New Client form pre-fills it); an explicit null = no tax.
Semantics
- Phones/emails replace wholesale on PATCH: if you send a
phones(oremails) array, the customer's existing list is deleted and rebuilt from what you sent. Omit the field to leave the existing list untouched. Send[]to clear it. - Every phone number is normalized to E.164 (
phoneE164) on write — this is what inbound SMS matches on. If no entry is flaggedisPrimary, the first one becomes primary. - On PATCH, a
nullon a non-nullable text column (title, companyName, notes, billing fields) clears it to"".leadSourceIdacceptsnullto detach.
Response (data): id, firstName, lastName, title, companyName, status, source,
notes, emailOptIn, smsOptIn, leadSourceId, billingAddressSource, billingAddressLine1/2,
billingCity, billingState, billingZipCode, billingCountry, createdAt, phones[]
(id, phone, label, isPrimary), emails[] (id, email, label, isPrimary),
properties[] (id, addressLine1/2, city, state, zipCode, country, notes,
contacts[]), tags[], balance. Since Oct 2026 (T-851 — the phone's client screen):
properties[].notes— the property's access notes ("gate 1234"),""when none;properties[].contacts[]— the people on site (id,name,phone,email,role), oldest first.tags— tag names, as the web client card shows them: the client's tags, or — only when it has none — the legacy free-text tags.balance— the account balance the web shows: the sum ofbalanceDueover the client's invoices awaiting payment (SENT/AWAITING_PAYMENT/PARTIAL/OVERDUE; drafts, paid and void invoices and gift-card loads do not count).nullfor a user token whose role lackspricing.vieworinvoices:view, and for a key without theinvoices:viewscope — the web hides the balance behind the same two grants. A number (0when nothing is owed) otherwise.
Since Oct 2026 (T-878 — everything the client blocks write, so a block PATCH built from the GET erases nothing):
tagIds— the ids of the client's tags (inactive ones included), oldest first. Send them back asclassification.tagIds;tags(names) stays for display. Legacy free-text tags have no id.referralCustomerId— the client who referred this one, ornull. Written byclassificationonly while thereferralsmodule is on.marketingPhotoConsent— user tokens only. Adw_live_key never receives the field, and itscontact-infoPATCH never changes the consent (the stored value is kept whatever is sent): granting or revoking a person's photo consent over a key needs its own design.
Tags picker — GET /api/v1/tags (clients:view): { "data": [{ "id", "name" }] }, the company's
ACTIVE tags by name — what the web Classification card offers. Not paginated.
DELETE detaches property joins (removing orphaned properties) then deletes the customer.
Returns { "data": { "deleted": true } }. A customer still referenced by other records returns
409 conflict.
Requests
requests scope. The whole-record routes are lead-capture only — no line items, no totals; the
request page's cards (assessment, line items) are edited through block edits.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/requests |
requests:view |
| POST | /api/v1/requests |
requests:create |
| GET | /api/v1/requests/:id |
requests:view |
| PATCH | /api/v1/requests/:id |
requests:edit |
| DELETE | /api/v1/requests/:id |
requests:delete |
| PATCH | /api/v1/requests/:id/blocks/:block |
requests:edit — see Block edits |
| POST | /api/v1/requests/:id/convert |
quotes:create (or jobs:create) and requests:edit |
List filters:
status— one ofRequestStatus.customerId— only requests linked to this client. Another company's id is not an error: the list is simply empty.search— case-insensitive substring overcontactName,titleand the linked client's first name, last name and company name; a bare number (1042or#1042) also matchesrequestNumberby equality (42does not return #142). Combines with the other filters and withcursor/limit— the match is done server-side, so the first page already carries it.
Create / patch body
| Field | Type | Create | Notes |
|---|---|---|---|
title |
string | required | Optional on PATCH. |
description |
string | null | optional | |
source |
enum RequestSource |
optional | |
status |
enum RequestStatus |
optional | |
customerId |
id | null | optional | Must be yours. A request can have only contact fields. |
propertyId |
id | null | optional | Must be yours. |
contactName |
string | null | optional | |
contactPhone |
string | null | optional | |
contactEmail |
email | null | optional | |
preferredDate |
ISO date-time | null | optional | |
alternateDate |
ISO date-time | null | optional |
Semantics: requestNumber is auto-assigned sequentially (max + 1 per company) on create.
Response: id, requestNumber, title, status, source, customerId, propertyId,
contactName, contactPhone, contactEmail, description, preferredDate, alternateDate,
subtotal, discountType, discountValue, taxRateId, taxInclusive, taxAmount,
minimumCharge, fees[], total, quoteId, jobId
(conversion links), createdAt, lineItems[] (always empty over the API until a line-items block
save), and (T-874) cityId, priceListId, promoCodeId, preferredArrivalTimes, bookedStart,
bookedEnd, notes (the first note's text, "" when none), noteFlags (linkToQuotes /
linkToJobs / linkToInvoices, all true when there is no note) and assessment (instructions,
startDate, startTime, endTime, scheduleLater, anytime, assignedToId, crewId,
helpers[] as { userId }, or null) — see Block edits.
Fees on money-bearing documents (Aug 2026)
Every quote / job / invoice / request DTO now carries the two figures that make its totals reconcilable:
| Field | Type | Notes |
|---|---|---|
minimumCharge |
number | The minimum-charge FLOOR, not an addend. When the line-item subtotal is below it, the difference is inside total |
fees |
[{ key, name, amount, visible }] |
The document's frozen fee snapshot, in the order it is printed. key is stable (travel, materials, or a company's own); name is the label at save time; visible: false = a fee configured "not shown on documents" (Sep 2026) |
promoCode |
string | null | The promo code applied to the document, as the customer typed it (Sep 2026) |
promoDiscountAmount |
number | null | The money the promo took off, already resolved — the promo term of the equation below (Sep 2026) |
taxName |
string | null | Name of the attached tax rate (GST). null when no rate is attached (Sep 2026) |
taxRate |
number | null | The attached rate as a FRACTION (0.05 = 5%, 0.09975 = QST), never a percent. null with taxName (Sep 2026) |
This is a breaking-ish addition, in the consumer's favour: before it existed the API emitted no
fee data at all, so subtotal + taxAmount != total for every document carrying a travel/materials
fee or a minimum charge. It now reconciles as
subtotal + minimumAdjustment - discount - promo + Σ fees.amount + taxAmount == total.
Two caveats: the fees array lists EVERY charged fee, including one configured "not shown on
documents" — that one carries visible: false, and the printed document gives it no row but folds
its amount into the Subtotal line; a client redrawing the totals block does the same (sum of
visible: false amounts added to subtotal), and Σ fees.amount is unchanged either way. And fees
are read-only, snapshotted from the customer's city at write time like the rest of the pricing
snapshot.
Tax label. Only taxAmount is snapshotted on a document; taxName/taxRate are read LIVE from
the attached rate, so renaming a rate in Settings → Taxes relabels every document that uses it (the
web and PDF do the same). The web shows a Tax (GST 5%) row whenever a rate is attached — even at
$0.00 — and a bare Tax row only when there is a non-zero amount with no rate.
Of the promo snapshot only the code and the resolved amount are exposed; the type/value/id columns behind it stay internal.
Read-only is not the same as untouchable. Since Aug 2026 an operator can override a document's
fee amounts and minimum charge in the app (feesOverridden, see
features/pricing.md). The API offers no way to set one — but it will not wipe
one either: a PATCH that moves the customerId or propertyId re-resolves the whole pricing
snapshot except the fee columns of an overridden document. Re-resolving them would revert a
hand-priced fee table on the very copy the customer receives, on behalf of an integration that never
asked to price it.
Quotes
quotes scope.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/quotes |
quotes:view |
| POST | /api/v1/quotes |
quotes:create |
| GET | /api/v1/quotes/:id |
quotes:view |
| PATCH | /api/v1/quotes/:id |
quotes:edit |
| DELETE | /api/v1/quotes/:id |
quotes:delete |
| POST | /api/v1/quotes/:id/send |
quotes:edit |
| POST | /api/v1/quotes/:id/mark-sent |
quotes:edit |
| PATCH | /api/v1/quotes/:id/blocks/:block |
quotes:edit — see Block edits |
| POST | /api/v1/quotes/:id/approve |
quotes:edit |
| POST | /api/v1/quotes/:id/decline |
quotes:edit |
| POST | /api/v1/quotes/:id/convert |
jobs:create (or invoices:create) and quotes:edit |
List filters: status (QuoteStatus), customerId, search — case-insensitive substring
over title and the client's first name, last name and company name, plus quoteNumber by equality
when the text is a bare number (1042 / #1042), exactly as on /requests.
A created quote gets the next quoteNumber (Settings → Quotes start number honoured), as the New
Quote form pre-fills it.
Create / patch body
| Field | Type | Create | Notes |
|---|---|---|---|
customerId |
id | required | Optional on PATCH; must be yours. |
propertyId |
id | null | optional* | *Required in effect on CREATE: omitted (or null) resolves to the client's ONLY property; 422 validation_failed when the client has none or several, or when the property isn't linked to that client. Stays optional on PATCH. |
title |
string | null | optional | |
notes |
string | null | optional | |
status |
enum QuoteStatus |
optional | |
discountType |
enum DiscountType | null |
optional | PERCENT / FIXED. |
discountValue |
number ≥ 0 | null | optional | |
taxRateId |
id | null | optional | Must be yours. Omitted on create → the property's rate, else the company default (what the form pre-selects); an explicit null = no tax. |
taxInclusive |
boolean | optional | true = the unitPrices already contain the tax, so it is extracted from the total instead of added. Omitted on create → the company's Settings → Taxes default; omitted on PATCH → unchanged. |
lineItems |
array of line item | optional | Defaults to [] on create. |
Line item: { productServiceId? (id, must be yours), name (required), description?, qty (> 0), unitPrice (≥ 0), isTaxable? (default true) }. productServiceId may be omitted for free-text lines.
qty takes at most 3 decimals. On create (POST quotes / jobs / invoices) a line WITH a
productServiceId must also fit the catalog's qtyDecimalPlaces (whole numbers today) — 1.5 of a
catalog item is 422 validation_failed (details: { "lineItems.0.qty": ["Quantity must be a whole number"] }) and nothing is created. Free-text lines keep the 3-decimal cap (2.5 hours works), and
PATCH / line-item PUT keep it for every line so a document holding a legacy fractional qty stays
saveable (Oct 2026).
Semantics
lineItemsreplaces wholesale on PATCH: sending the array deletes and rebuilds all lines.- Totals are server-computed and read-only:
subtotal,taxAmount,totalare always recomputed from the line items, discount, and the pricing snapshot — you cannot set them. - Pricing (fees, minimum charge, price level) is snapshotted from the customer's city at write
time, exactly as the in-app form does. The API does not accept a
cityId— it derives the city from the customer. The resolved amounts are returned asminimumCharge+fees[](see above). - A
CONVERTEDquote is frozen (as in the app): PATCH returns409 conflictfor anything butnotes, andstatus: CONVERTEDcannot be set by PATCH — only a conversion writes it. statusfollows the app's transition table (the status select on/quotes/[id]):DRAFT → AWAITING_RESPONSE / ARCHIVED;AWAITING_RESPONSE → APPROVED / DECLINED / DRAFT / ARCHIVED;APPROVED → DECLINED / AWAITING_RESPONSE / ARCHIVED;DECLINED → APPROVED / AWAITING_RESPONSE / DRAFT / ARCHIVED;ARCHIVED → DRAFT. Anything else is409 conflictwith the app's sentence (Cannot change status from Draft to Approved) and nothing in the PATCH is written. Sending back the storedstatusis not a change.
Response: id, quoteNumber, title, status, customerId, propertyId, notes,
discountType, discountValue, taxRateId, taxInclusive, subtotal, taxAmount,
minimumCharge, fees[], total, depositRequired, depositType, depositAmount,
clientMessage, contractDisclaimer, createdAt, validUntil, cityId, priceListId,
promoCodeId (T-874), lineItems[] (id, productServiceId, name, description, qty, unit,
unitPrice, total, isTaxable, isOptional, isTextOnly — the same line shape on jobs, invoices
and requests).
Deposit and texts (Oct 2026, T-851): depositType is PERCENT or FIXED (null when never set);
depositAmount is the percent for PERCENT (20 = 20%) and the money amount for FIXED, and is
null without pricing.view either way. clientMessage / contractDisclaimer are the texts the
client sees on the quote, null when empty.
Send a quote — POST /api/v1/quotes/:id/send
Sends the quote to its client exactly like the Send button on /quotes/[id]: the company's
Quote Email/SMS template, the PDF attached to the email, then status → AWAITING_RESPONSE with
sentAt stamped (and the previous round's approve/decline/view stamps cleared). PATCH {status}
only moves the status and sends nothing — use this endpoint when the client must actually receive it.
Body (optional — an empty POST sends by email with the PDF):
| Field | Type | Default | Notes |
|---|---|---|---|
channel |
email | sms |
email |
sms needs the SMS Notifications plan module. |
attachPdf |
boolean | true |
Email only. |
The recipient is the client's primary email (or primary phone for sms); the client's opt-in flags
do not block an operator-initiated send, same as the web.
| Status | Code | When |
|---|---|---|
| 200 | — | Sent; the quote DTO in its new status. |
| 404 | not_found |
No such quote in your company. |
| 409 | conflict |
The quote is CONVERTED or ARCHIVED (a re-send while AWAITING_RESPONSE is allowed). Nothing is sent. |
| 422 | validation_failed |
No client, the client has no email/phone for the channel, or the send was refused (e.g. SMS module off). The status is unchanged. |
Mark a quote sent — POST /api/v1/quotes/:id/mark-sent
The quote was handed to the client some other way (printed, shown on the phone) — exactly like
Mark as sent on /quotes/[id], and no message is sent: status → AWAITING_RESPONSE with
sentAt stamped now and the previous round's approve/decline/view stamps cleared. PATCH {status}
moves only the status and stamps nothing. No body. A quote already AWAITING_RESPONSE answers 200
unchanged.
| Status | Code | When |
|---|---|---|
| 200 | — | Marked sent; the quote DTO in its new status. |
| 404 | not_found |
No such quote in your company. |
| 409 | conflict |
The transition table forbids it (CONVERTED, ARCHIVED) — "Cannot mark a quote as sent while it is Converted". Nothing is written. |
Block edits (web parity)
The record pages of the app edit a document card by card ("blocks"). The API offers the same doors,
for the mobile app first: each block route runs the very core the web card's save runs — the same
validation, the same status locks, the same single totals writer (recomputeQuoteTotals /
recomputeInvoiceTotals), the same refusal sentences. The older whole-record PATCH /:id routes keep
their own contract and stay as they are.
PATCH /api/v1/<entity>/:id/blocks/:block — the body is the block's JSON payload, exactly what the web
card posts (fields absent from it take the card's defaults, so send the whole block):
| Entity | Blocks | Scope | Lock |
|---|---|---|---|
quotes |
details, line-items, message, contract, notes |
quotes:edit |
CONVERTED refuses every block but notes |
invoices |
details, line-items, message, contract, notes |
invoices:edit |
PAID refuses every block; VOID every block but notes |
requests |
details, assessment, line-items, notes |
requests:edit |
— |
customers |
identity, contact-info, classification, billing, pricing, social-links |
clients:edit |
a plan-locked client → 403 plan_locked |
line-items carries items[] (name, qty, unitPrice, isTaxable, isOptional, isTextOnly,
productServiceId, unitCost, …), taxRateId, discountType/discountValue, priceListId, cityId,
promoCodeId, taxInclusive and (quotes) extras — the deposit or the payment schedule. A caller
whose role cannot see prices is refused (403). On quotes and requests send each existing row's
lineItemId (the stored line's id) back: a user token whose role lacks costs.view has its posted
unitCost ignored — a row whose lineItemId names a line of THIS document with the same product keeps
that line's stored cost, any other catalog row takes the catalog's cost, a free-text row 0 (the same
rule as PUT /visits/:id/line-items). A company key keeps writing unitCost. contact-info stores every phone with its E.164 twin (a typed (416) 555-0199 is accepted — the
route applies the web input's mask);
classification validates every tagIds entry against your company. Build each body from the GET:
the client DTO carries tagIds, referralCustomerId and (user tokens) marketingPhotoConsent; the
invoice DTO carries clientMessage, contractDisclaimer and customFields — contact-info without
the consent revokes it, details without customFields clears them. The request notes block carries
flags (linkToQuotes / linkToJobs / linkToInvoices); a body without flags resets all three to
true — always send them.
Round-trip — build the block body from GET /quotes/:id / GET /requests/:id / GET /invoices/:id. Every field a
block writes is in the DTO (T-874, invoices T-877), so GET → edit → PATCH never erases what the office set on the web:
| Block | DTO fields to send back |
|---|---|
quote details |
title, quoteNumber, customerId, propertyId, validUntil (as YYYY-MM-DD) |
line-items (quote / request / invoice) |
lineItems[] → items[] with lineItemId = the line's id, name, description, qty, unit, unitPrice, isTaxable, isOptional, isTextOnly, productServiceId, and on an invoice performedById; plus discountType, discountValue, taxRateId, taxInclusive, cityId, priceListId, promoCodeId |
request notes |
notes (the request's first note) → notes, noteFlags → flags |
request assessment |
preferredDate / alternateDate (as YYYY-MM-DD), preferredArrivalTimes, bookedStart / bookedEnd → bookedStartTime / bookedEndTime (as HH:MM), assessment (its startDate as YYYY-MM-DD, helpers as { userId }[]) |
Calendar days and window times are UTC walltime and come back as ISO strings: the day is the first ten
characters, the time characters 11–16. A request line has no unit or optional flag (always "" /
false). The quote's deposit / payment schedule (extras) may simply be left out — an absent extras
leaves both untouched. Fees: a document whose fees were edited by hand keeps that override through
a line-items save that carries no feeOverride (the DTO has no fee ids to post one with); without
an override the fees re-resolve from the city as on the web. Resetting an override is web-only.
Answers: 200 with the record's DTO · 404 not_found (no such record in your company, or an unknown
block) · 422 for a sample (demo) client named in details · 409 conflict with the web's lock sentence · 422 validation_failed (message + details
field errors; a foreign tag / client / tax rate id lands here) · 403 (scope / role).
Quote status and conversion — POST /quotes/:id/approve and /decline are the operator's
Approve / Decline buttons (the transition table decides; a refused move is 409, the same status again
is a no-op). POST /quotes/:id/convert with { "target": "job" | "invoice" } builds the job or invoice
with the web's own builder (lines without the optional ones, tax, fees, discount, promo, the deposit /
payment plan, the property through the required-property resolver) and stamps the quote CONVERTED.
It answers 201 { "data": { "target": "job", "id": "<new id>" } }. A quote already converted to a
job (or with a job linked) is 409. POST /requests/:id/convert with { "target": "quote" | "job" } is
the request page's Convert menu — the request ends QUOTED / CONVERTED and links the new document;
a second conversion to the same kind is 409. A conversion that would exceed the plan's limit for the
new document (or the active-client cap) is 403 plan_limit_reached with the web's sentence — nothing is
created and the quote / request keeps its status.
Client properties — POST /customers/:id/properties creates a service address linked to the
client: addressLine1 (required), addressLine2, city, state, zipCode, country, notes,
latitude, longitude, taxRateId and contacts[] (name, email, phone, role). An absent
taxRateId takes the company's default tax rate (what the web dialog pre-fills); null means none.
PATCH …/properties/:propertyId is partial (absent fields — contacts included — keep their stored
values); DELETE refuses (409, the web's sentence) a property a quote, job, invoice or request still
names. A property of another company, or not linked to this client, is 404.
Jobs
jobs scope.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/jobs |
jobs:view |
| POST | /api/v1/jobs |
jobs:create |
| GET | /api/v1/jobs/:id |
jobs:view |
| PATCH | /api/v1/jobs/:id |
jobs:edit |
| PATCH | /api/v1/jobs/:id/details |
jobs:edit |
| PATCH | /api/v1/jobs/:id/schedule |
jobs:edit |
| PATCH | /api/v1/jobs/:id/billing |
jobs:edit |
| PATCH | /api/v1/jobs/:id/line-items |
jobs:edit |
| PATCH | /api/v1/jobs/:id/notes |
jobs:edit |
| DELETE | /api/v1/jobs/:id |
jobs:delete |
| POST | /api/v1/jobs/:id/invoices |
invoices:create |
List filters: status (JobStatus), customerId.
List order (order, Oct 2026): absent or created — newest created first (the default);
start — by startAt, earliest first, jobs with no start date last; the cursor walks the same
order. Any other value is 422 validation_failed.
Create — POST /api/v1/jobs
Runs the SAME create as the app's New Job form (Oct 2026): a dated job gets its visit(s), a
repeating job its whole series, the lead and helpers their roster and a place on every visit's crew
(and a visit.assigned push), an unnumbered job the next number.
| Field | Type | Create | Notes |
|---|---|---|---|
customerId |
id | required | Must be yours. |
title |
string | required | |
propertyId |
id | null | optional* | *Required in effect: omitted (or null) resolves to the client's ONLY property; 422 validation_failed when the client has none or several, or when the property isn't linked to that client. |
quoteId |
id | null | optional | Must be yours. |
jobNumber |
integer ≥ 1 | optional | Omitted = the next number (highest + 1, what the form pre-fills). A number another job already has is 422 on jobNumber. |
notes |
string | null | optional | |
instructions |
string | optional | The crew brief. |
status |
enum JobStatus |
optional | DONE / CANCELED together with a window (startAt without scheduleLater) is 422 on status — a closed job gets no visits. |
startAt |
ISO date-time | null | optional | UTC wall time (2026-10-20T09:00:00.000Z = 09:00 on the company clock). The start of the first (or only) visit. |
endAt |
ISO date-time | null | required with a timed startAt |
Same day as startAt and after it; its time is every visit's end time. Missing, on another day, or not after the start → 422 on endAt. Not needed with anytime / scheduleLater; a date with no times is startAt = endAt = midnight. |
anytime |
boolean | optional | A day without a time: the visit(s) are Anytime (date, no times). |
scheduleLater |
boolean | optional | No window, no visit — "To be scheduled" in the app and the Unscheduled panel. Omitted = true exactly when startAt is absent; an explicit false without startAt is 422 on startAt. |
assignedToId |
id | null | optional | The lead technician; a member of your company. |
helperUserIds |
id[] | optional | Helpers; each a member of your company (422 otherwise). |
crewId |
id | null | optional | Crew label only — the people are the lead + helpers. |
recurrencePattern |
DOES_NOT_REPEAT DAILY WEEKLY BIWEEKLY MONTHLY YEARLY EVERY_WEEKDAY CUSTOM |
optional | Anything but DOES_NOT_REPEAT makes the job recurring and needs startAt. |
recurrenceEndType |
NEVER AFTER_COUNT ON_DATE | null |
optional | |
recurrenceEndCount |
integer 1–520 | null | optional | Required with AFTER_COUNT. |
recurrenceEndDate |
YYYY-MM-DD | null |
optional | Required with ON_DATE; before the start date is 422 on recurrenceEndDate. |
recurrenceInterval |
integer 1–99 | null | optional | CUSTOM: every N units. |
recurrenceUnit |
DAY WEEK MONTH YEAR | null |
optional | Required with CUSTOM. |
recurrenceDaysOfWeek |
string | optional | CUSTOM weekly days as a comma list, 0 = Monday … 6 = Sunday ("0,3" = Mon + Thu). |
recurrenceMonthlyMode |
DAY_OF_MONTH NTH_WEEKDAY | null |
optional | |
recurrenceMonthlyDays |
string | optional | "9,17,LAST". |
recurrenceMonthlyWeekdays |
string | optional | ordinal:day pairs, "1:0,3:4" (day 0 = Monday). |
discountType |
enum DiscountType | null |
optional | |
discountValue |
number ≥ 0 | null | optional | |
taxRateId |
id | null | optional | Omitted = the default — the property's tax rate, else the company default (what the form pre-selects). An explicit null means no tax. |
taxInclusive |
boolean | optional | Omitted = the company default. |
lineItems |
array of line item | optional | Defaults to []. |
Visit dates are the app's own series engine; billing (invoicing frequency, reminder, billing type) takes the company's Settings → Jobs defaults. City, price list, fees and minimum are derived from the service property's address, as before.
Edit — one endpoint per block of the job page
Each block endpoint runs the app's own block save, with the app's locks, and replaces the whole
block — the body is the full card the job page posts, never a diff. This is enforced: a body
missing a required key of its block, or carrying a key the block does not own, is
422 validation_failed with nothing written — details names each key ("Required: PATCH /api/v1/jobs/:id/<block> replaces the whole block" / "Unknown field"), the message lists them
(… replaces the whole block — missing: type, helpers; unknown: helperUserIds). A body that is not
a JSON object is refused the same way. The check runs on key PRESENCE before the job is read (a
malformed body is 422 even on a foreign or locked id); the values are then the block schema's
business, and null on a nullable key (customerId, propertyId, assignedToId, crewId,
taxRateId, …) clears it. Read the job (GET /api/v1/jobs/:id — its blocks object holds every
block in exactly this shape), change what you need, send the block back whole:
PATCH /jobs/:id/schedule with dto.blocks.schedule, /line-items with dto.blocks.lineItems.
/details— required:title,jobNumber,customerId,propertyId,marketingPhotoExcluded. Locked atCANCELED./schedule— required:type(ONE_OFF/RECURRING),scheduleLater,anytime,startDate(YYYY-MM-DD),startTime/endTime(HH:mm,""for none),assignedToId(lead),crewId,emailOnAssign,instructions,helpers([{ "userId": "…" }]— nothelperUserIds),recurrencePattern. Required with the series they describe — only whentypeisRECURRINGandrecurrencePatternis notDOES_NOT_REPEAT:recurrenceEndType; with itrecurrenceEndCount(AFTER_COUNT) orrecurrenceEndDate(ON_DATE); andrecurrenceInterval+recurrenceUnitforCUSTOM. The keys the dates are BUILT from are required by the pattern that reads them — an absent one would be saved as cleared and the series rebuilt without it:recurrenceDaysOfWeekforCUSTOMwithrecurrenceUnitWEEK(ornull, which repeats weekly);recurrenceMonthlyModeforMONTHLYand forCUSTOMwith unitMONTH, and thenrecurrenceMonthlyWeekdayswhen the mode isNTH_WEEKDAY,recurrenceMonthlyDaysotherwise (DAY_OF_MONTHornull).""is the "none" of those three lists. TheWEEKLYpreset reads no weekday list. Optional:recurrenceEndPeriod, and the two go-aheadsconfirmVisitRemoval/confirmPerVisitExit(below).type: RECURRINGwithrecurrencePattern: DOES_NOT_REPEATis422unlessscheduleLateris true; a job madeONE_OFFis moved toinvoicingFrequency: ON_COMPLETIONin the same save. Saving regenerates the series the way the app does: only futureSCHEDULEDvisits sitting exactly on a slot of the PREVIOUS series are replaced; a visit someone moved, started, finished or skipped survives. A one-off job's next future visit moves with the window. Locked atDONEandCANCELED./billing— required:invoicingFrequency,billingType,remindToInvoice,splitIntoSchedule,paymentSchedule[]; optional:confirmPerVisitExit. Locked atCANCELED; needs prices (see below). A one-off job cannot be CHANGED toPER_VISIT/MONTHLY(422oninvoicingFrequency; a value it already holds still saves). Turning the split off while an installment invoice that is notVOIDstill bills it is422naming those invoices./line-items— required:items[](name,qty,unitPrice, optionalproductServiceId,description,unit,unitCost,isTaxable,isTextOnly),discountType,discountValue,taxRateId,priceListId,cityId,promoCodeId. Optional:taxInclusive(absent = keep the stored setting) andfeeOverride(absent = fees re-resolve from the city). Totals are recomputed by the one server writer. Locked atCANCELED; needs prices./notes— required:notes(the only key). Open in every status.
A locked block is 409 conflict with the app's sentence (This job was canceled — reopen it before editing. / Completed or canceled jobs can't be rescheduled.). A user token whose role cannot see
prices gets 403 prices_hidden on /billing and /line-items. Response: the job (as GET).
Ask first — 409 confirmation_required (Oct 2026): a save that would destroy something the app
asks about writes NOTHING and answers 409 with the app's question as message and ONE key in
error.details — the body flag that answers it. Re-send the same body with that flag true to go
ahead (a second question may follow; answer one at a time): confirmVisitRemoval — /schedule
turning a series one-off would delete upcoming visits (the message counts them);
confirmPerVisitExit — /billing leaving PER_VISIT on a job whose lines price ONE visit, or
/schedule making such a job one-off (the job would bill its whole total at one visit's price).
Who reaches a job (user tokens, as in the app): a role without "Sees everyone's visits"
(allVisits.view) reaches only the jobs it works — assignee, roster, or crew of one of its visits;
any other job of the company is 404 not_found, on every block endpoint and equally on
GET, PATCH and DELETE /api/v1/jobs/:id (nothing is read, written or deleted). A role without allVisits.edit gets
403 not_assigned on a /schedule save that would delete or move a visit whose crew it is not on.
A company key is limited by neither.
PATCH /api/v1/jobs/:id
Edits quoteId, notes, status. The details, the schedule and the money have their own
endpoints (Oct 2026): customerId, propertyId, title here are 422 validation_failed naming
/details; startAt, endAt, assignedToId name /schedule; and lineItems, discountType,
discountValue, taxRateId, taxInclusive name /line-items — this endpoint used to move the
job's window without moving a visit, and to re-price the job on a client change the app does not.
Status locks (as in the app): a CANCELED job accepts only notes and status (so it can be
reopened) — anything else is 409 conflict. The lock reads the STORED status, so reopening and
editing in one request is refused — send two requests.
Scope: a user token without allVisits.view gets 404 not_found here on a job it does not work
(see "Who reaches a job" above) — it cannot edit the notes or change the status of a job it cannot
open; a company key is not limited.
Response: id, jobNumber, title, status, type, invoiceIds, billsBySchedule, unscheduled,
customerId, propertyId, quoteId,
assignedToId, customerName, assignedToName, hasConflict, startAt, endAt, notes,
discountType, discountValue, taxRateId, taxInclusive,
subtotal, taxAmount, minimumCharge, fees[], total, createdAt, lineItems[], and on
the single-job responses (GET /jobs/:id, POST /jobs, PATCH /jobs/:id and every block PATCH —
not the list) blocks.
blocks (Oct 2026, T-832) is the job as its five edit endpoints take it — each value is a complete
body for PATCH /api/v1/jobs/:id/<block> and passes its whole-block check, so a client sends back
what it read with only its own change:
| Key | Endpoint | Holds |
|---|---|---|
blocks.details |
/details |
title, jobNumber, customerId, propertyId, marketingPhotoExcluded |
blocks.schedule |
/schedule |
type, scheduleLater, anytime, startDate (YYYY-MM-DD of the UTC-walltime start), startTime/endTime (HH:mm, "" none), assignedToId (lead), crewId, emailOnAssign, instructions, helpers ([{userId}], the roster minus the lead), every recurrence* key (recurrenceEndDate as YYYY-MM-DD; the three lists "" when unset; recurrencePattern DOES_NOT_REPEAT on a one-off) |
blocks.billing |
/billing |
invoicingFrequency, billingType, remindToInvoice, splitIntoSchedule, paymentSchedule[] (installmentNumber, amountType, amountValue, dueDescription) |
blocks.lineItems |
/line-items |
items[] (each with lineItemId — the stored line it is, productServiceId only on a catalog line, name, description, qty, unit, unitPrice, unitCost, total, isTaxable, isTextOnly), discountType, discountValue, taxRateId, taxInclusive, priceListId, cityId, promoCodeId, and feeOverride only when the job's fees were edited by hand (absent re-resolves them) |
blocks.notes |
/notes |
notes |
Grants: without pricing.view (a user token) blocks.lineItems and blocks.billing are null —
those two PATCHes answer 403 prices_hidden anyway. Without costs.view no line carries unitCost;
keep each line's lineItemId when you send the lines back — that is what keeps the stored cost (a
row without one is costed from the catalog, a free-text row at 0). Line ids change on every line
save; read them again from the response.
customerName (the client's display name, null without a client), assignedToName ("First
Last", null when unassigned) and hasConflict (the assignee has another job overlapping this
one's window — the cached flag behind the conflict badge of the web jobs table) are on every job response, list and detail
(Oct 2026, T-851).
type (ONE_OFF / RECURRING), invoiceIds (the job's invoices that are not VOID, oldest
first — ids only; reading one needs invoices:view) and billsBySchedule (true when the job is
billed through its payment-schedule installments — then POST /jobs/:id/invoices is refused) are on
every job response (Oct 2026, T-870).
unscheduled (Oct 2026, T-914) is true when the job is SCHEDULED but has no visit SCHEDULED or
IN_PROGRESS — no visits at all (as right after Convert to Job), or only completed / skipped /
dateless ones. It is the web's display-only "Unscheduled" status: status itself stays SCHEDULED
(there is no such stored status), so a client shows "Unscheduled" instead of the job's startAt.
Any other status answers false.
Invoice the job — POST /api/v1/jobs/:id/invoices
The same build as the app's Create Invoice button on the job: lines (or, with none, the quote's
non-optional items), discount, tax rate and mode, fee snapshot, minimum charge, promo, the company's
payment terms and due date. Use it instead of POST /api/v1/invoices with client-assembled lines
whenever an invoice bills a job. Answers 201 with the invoice (same shape as GET /invoices/:id).
| Field | Type | Notes |
|---|---|---|
visitId |
id | Per Visit jobs: the visit to bill (required for them — 422 without). Monthly jobs: bills that visit's month. |
month |
YYYY-MM |
Monthly jobs: the month whose completed visits to bill. |
acknowledgedInvoiceIds |
array of ids | Confirms a repeat (below). |
The body is optional — a whole-job invoice posts none.
| Status | Code | When |
|---|---|---|
| 404 | not_found |
Not your job. |
| 409 | invoice_exists |
Already billed: a one-off job with a live invoice, or the visit / month already invoiced. error.details.acknowledgedInvoiceIds lists them — re-post with exactly those ids to create another (the app's confirmation). On a one-off job the new invoice bills only what is LEFT of the job (total minus its live invoices) as one line with its share of the tax. |
| 409 | conflict |
The job is CANCELED, already billed in full by its live invoices, bills by payment schedule (billsBySchedule), has no client, the visit was skipped, or the month has no completed visits — the message is the app's. |
| 422 | validation_failed |
Missing visitId / month the job's billing frequency needs, an unknown visit, or no usable property. |
| 403 | plan_limit_reached |
Invoice plan limit. |
Invoices
invoices scope.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/invoices |
invoices:view |
| POST | /api/v1/invoices |
invoices:create |
| GET | /api/v1/invoices/:id |
invoices:view |
| PATCH | /api/v1/invoices/:id |
invoices:edit |
| DELETE | /api/v1/invoices/:id |
invoices:delete |
| POST | /api/v1/invoices/:id/mark-sent |
invoices:edit |
| POST | /api/v1/invoices/:id/send |
invoices:edit |
| PATCH | /api/v1/invoices/:id/blocks/:block |
invoices:edit — see Block edits |
List filters: status (InvoiceStatus), customerId.
Create / patch body
| Field | Type | Create | Notes |
|---|---|---|---|
customerId |
id | required | Optional on PATCH; must be yours. |
jobId |
id | null | optional | Must be yours. |
quoteId |
id | null | optional | Must be yours. |
propertyId |
id | null | optional* | *Required in effect on CREATE: omitted (or null) resolves to the client's ONLY property; 422 validation_failed when the client has none or several, or when the property isn't linked to that client. Stays optional on PATCH. |
title |
string | null | optional | |
notes |
string | null | optional | |
status |
enum InvoiceStatus |
optional | |
issueDate |
ISO date-time | null | optional | |
dueDate |
ISO date-time | null | optional | |
paymentTerms |
string | null | optional | NET_15 / NET_30 / NET_45 / NET_60 / DUE_ON_RECEIPT / CUSTOM. See due date below. |
clientMessage |
string | null | optional | Create only — the message shown to the client on the invoice. Not accepted by PATCH (edit it through the message block). |
number |
positive int | optional | Omit to auto-assign; unique per company. |
discountType |
enum DiscountType | null |
optional | |
discountValue |
number ≥ 0 | null | optional | |
taxRateId |
id | null | optional | Omitted on create → the property's rate, else the company default; null = no tax. |
lineItems |
array of line item | optional | Defaults to [] on create. |
Semantics
numberauto-allocates (max + 1 per company) when omitted, retrying on concurrent collisions. Supplying anumberalready taken returns409 conflict.- Due date on create follows the app's new-invoice form (Oct 2026): an explicit
dueDatealways wins (with nopaymentTerms, the terms are stored asCUSTOM); otherwisepaymentTerms— the body's, else the company's Settings → Invoices terms, elseNET_30— sets the due date fromissueDate(today when absent):NET_15on an issue date of 2026-10-01 is due 2026-10-16.DUE_ON_RECEIPT(andCUSTOMwithout a date) has no due date. PATCH changes neither implicitly. lineItemsreplace wholesale;subtotal/taxAmount/totalare server-computed;amountPaid/balanceDue/statusare recomputed from recorded payments. All are read-only.- DELETE is blocked with
409 conflictif the invoice has any payments, payment allocations, or refunds (money records are never destroyed). - A
PAIDinvoice is frozen (as in the app): every PATCH,notesandstatusincluded, returns409 conflict. Sending back the storedstatusunchanged is not an edit. PAIDandPARTIALcannot be set by PATCH, and aPARTIALinvoice cannot be moved off it — both follow the invoice's recorded payments, never the field. Record the money withPOST /api/v1/paymentsand the status follows. Such a PATCH is409 conflictand nothing in it is written; sending back the storedstatusis not a change.
Response: id, number, title, status, customerId, jobId, quoteId, propertyId,
notes, issueDate, dueDate, paymentTerms, discountType, discountValue, taxRateId, taxInclusive,
subtotal, taxAmount, minimumCharge, fees[], total, amountPaid, balanceDue, paidAt,
customerName, sentAt, refunds[], clientMessage, contractDisclaimer, customFields[],
cityId, priceListId, promoCodeId, createdAt, lineItems[] (each line also carries
performedById — who performed it on a salon ticket, null otherwise).
Since Oct 2026 (T-877): cityId, priceListId, promoCodeId and the lines' performedById — what
the line-items block writes whole, so a body built from the GET keeps them (ids, visible without
pricing.view). A stored fee override survives a line-items save without feeOverride, as on quotes.
Since Oct 2026 (T-878): clientMessage and contractDisclaimer (the Message and Contract cards, null
when empty) and customFields[] — { id, title, value } (strings), the Details card's custom rows, []
when none. These are exactly what message / contract / details write.
Since Oct 2026 (T-851), on every invoice response: customerName (the client's display name),
sentAt (ISO instant the invoice was issued — sent or marked sent; null on a draft) and
refunds[], the web's Refund History, newest first:
| Field | Notes |
|---|---|
id, createdAt |
createdAt = when the refund was issued (ISO instant). |
amount |
The refunded amount; null without pricing.view. |
reason |
Free text, null when none was given. |
paymentId, paymentMethod |
The refunded payment and its method key (as payments[].method); both null on a legacy invoice-level refund. |
origin |
stripe (a card refund through Stripe), chargeback (a lost dispute) or manual (recorded by hand). |
failedAt |
Set when an asynchronous refund failed — such a row did not change the balance (the web strikes it through). |
Mark an invoice sent — POST /api/v1/invoices/:id/mark-sent
Issues a DRAFT invoice without sending any message — it was handed over some other way (shown
on the phone, printed), exactly like Mark as sent on /invoices/[id]: status → SENT, sentAt
stamped now, and issueDate set to today in the company's timezone when the draft had none (an
issue date already picked is kept). A draft is unpayable, so this is what makes it collectable.
PATCH {status: "SENT"} moves only the status and stamps neither date. No body.
| Status | Code | When |
|---|---|---|
| 200 | — | Marked sent; the invoice DTO in its new status. |
| 404 | not_found |
No such invoice in your company. |
| 409 | conflict |
The invoice is not DRAFT — "Only draft invoices can be marked as sent." Nothing is written. |
Send an invoice — POST /api/v1/invoices/:id/send
Sends the invoice to its client exactly like the Send button on /invoices/[id]: the company's
Invoice Email/SMS template with the online-pay link (and the e-Transfer instructions when that
method is offered), the PDF attached to the email; then a DRAFT becomes SENT with sentAt and,
when it had none, today's issueDate (company timezone). A re-send of an issued invoice refreshes
sentAt only — the status is never walked back. mark-sent is the no-message twin.
Body (optional — an empty POST sends by email with the PDF):
| Field | Type | Default | Notes |
|---|---|---|---|
channel |
email | sms |
email |
sms needs the SMS Notifications plan module. |
attachPdf |
boolean | true |
Email only. |
recipients |
string[] (≤ 10) | the client's primary email / phone | Addresses (or numbers for sms) chosen in the app, like the web Send dialog's recipient list. |
subject |
string | the template's | Email only. |
body |
string | the template's | A blank body falls back to the template. |
| Status | Code | When |
|---|---|---|
| 200 | — | Sent; the invoice DTO. |
| 404 | not_found |
No such invoice in your company. |
| 409 | conflict |
The invoice is PAID or VOID — there is nothing to collect. Nothing is sent. |
| 422 | validation_failed |
The client has no email / phone for the channel, or the send was refused. Nothing is written. |
Tasks
tasks scope. A task is the same record as a kanban card on /tasks — reads and moves cover
the boards, so an integration can pull work from a column, claim it, and report back.
Two asymmetries to know:
- POST creates calendar-only tasks (
board/columnarenull). Placing a card on a board is a PATCH — see Moving a card. - A key's reads/edits cover every task in the company, including cards on private boards the key's creator cannot see in the app; a user token sees only the boards the app shows that member — see Trust model.
The board's own configuration — columns, labels, custom-field definitions — is Boards,
under the same tasks scope.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/tasks |
tasks:view |
| POST | /api/v1/tasks |
tasks:create |
| GET | /api/v1/tasks/:id |
tasks:view |
| PATCH | /api/v1/tasks/:id |
tasks:edit |
| DELETE | /api/v1/tasks/:id |
tasks:delete |
| GET | /api/v1/tasks/:id/comments |
tasks:view |
| POST | /api/v1/tasks/:id/comments |
tasks:edit |
| GET | /api/v1/tasks/:id/activity |
tasks:view |
| POST | /api/v1/tasks/:id/attachments |
tasks:edit |
| DELETE | /api/v1/tasks/:id/attachments/:attachmentId |
tasks:edit |
One PATCH covers everything a human can change on a card: placement, labels, the checklist,
the content blocks (addressable by their title), the custom-field values, and archive/restore.
It is atomic on validation — an id anywhere in the body that does not resolve rejects the whole
request and writes nothing, so a rejected label never leaves a half-applied title behind.
List filters
| Param | Matches |
|---|---|
status |
ScheduleTaskStatus: PENDING / COMPLETED. |
assignedToId |
Task assignee. |
boardId |
All cards on that board. Also scopes the four params below. |
columnId |
One column, by id. |
column |
One column, by name (case-insensitive). Combine with boardId when the same column name exists on several boards. |
labelId |
Cards carrying that label, by id. |
label |
Cards carrying that label, by name (case-insensitive). |
parentId |
One card's sub-cards, by parent id. none returns the ROOT cards (no parent). See Sub-cards. |
archived |
true / 1 → return only archived cards. Absent or anything else → only live ones. |
dueFrom / dueTo |
YYYY-MM-DD — cards whose due DAY is in the range, both ends inclusive (an Anytime card on that day included). The day is the walltime date the card stores, not an instant. Not a date → 422 validation_failed naming the parameter. |
order |
due → due date, then the clock time on that day, then priority (URGENT first); cards without a time come after the timed ones of their day, cards without a due date last. Absent → newest card first (id desc). Anything else → 422. The cursor walks whichever order you asked for. |
Archived cards are not listed by default (a direct GET /tasks/:id still resolves one, so an
integration holding an id isn't broken — the response carries archived: true). ?archived=true
flips the list to the archive, with every filter above still applying — that is what makes
restoring reachable for a card whose id you did not hold before it was archived.
It returns the archive INSTEAD of the live list, never a union: "everything" is two calls, and a merged list gives you no way to tell the two halves apart.
Unresolved name or id ⇒ an empty list, never an unfiltered one. A misspelled
column=Redyreturns{ "data": [], "pagination": { "nextCursor": null, "hasMore": false } }, not the whole backlog. The same holds for a column/label belonging to another company. If you get zero rows where you expected work, check the spelling before concluding the column is empty.
Response shapes
The list DTO (GET /tasks, POST /tasks):
id, number, title, description, dueDate, dueTime, remindAt, status, priority,
assignedToId, relatedJobId, relatedCustomerId, board ({id,name} | null), columnId
(id | null), column ({id,name,isDone,category} | null), labels[] ({id,name,color}),
dependsOn[], blockedBy, parentId (id | null), parent
({id,number,title,archived} | null), subCards ({done,total}), customFields[]
({id,name,type,value}), archived, createdAt, updatedAt.
customFields is the card's custom-field values joined with its board's definitions — the same
array, built by the same code, as on the detail DTO (see the table below): every field defined on
the board appears, an unanswered one as value: null, a select by its option name; a
calendar-only card has []. It is on the list so a scan by field ("every Kind = Bug in Ready")
is one page request filtered on your side, not a GET /tasks/:id per card. There is no server-side
filter by field value.
number is the card's human-readable id — an integer, unique per company, counted from 1 and
displayed in the app as T-<number> (e.g. T-142). It is assigned once at creation, is read-only,
and does not change when the card moves to another board, is archived or is restored. Quote it when
you reference a card to a human; keep using id for API calls.
columnId and column are two projections of the same fact, and both are on the wire on
purpose: the raw id is what you send back on a PATCH (or compare between two cards), the nested
object is what you print. columnId: null means exactly one thing — the card is calendar-only,
sitting on no board — and column and board are then null too. A card that is on a board
always has both. So if a whole board's worth of cards reads null here, the key is missing from
the payload rather than empty in it: check that the field is present before concluding the cards
have no column.
The detail DTO (GET /tasks/:id, PATCH /tasks/:id) is the list DTO plus the card's content:
| Field | Type | Notes |
|---|---|---|
notes |
string | All notes blocks, flattened to plain text, blank-line separated. |
notesHtml |
string | The same blocks as stored (sanitized) HTML, newline separated. |
blocks |
{id,title,type,content}[] |
The card's SECTIONS in board order. type is TEXT / CHECKLIST / ATTACHMENTS; title is the operator's heading and "" means the app renders the per-type default label (Notes / Checklist / Attachments). content is the stored sanitized HTML and is always "" for a non-TEXT block. This is what makes a section addressable by name on write. |
checklist |
{id,text,done,blockId}[] |
In card order (by block, then by item). blockId says which section an item sits under — a card may carry several checklists. |
attachments |
{id,fileName,mimeType,fileSize,url}[] |
See Attachment URLs. |
customFields |
{id,name,type,value}[] |
Also on the list DTO, identical. Definitions come from the board; value is null when the card hasn't set it. Only fields still defined on the board appear. A select reports the option's name, not its stored id; a value matching no (current) option is returned verbatim. Write them with customFields — see Custom field values. |
children |
{id,number,title,columnId,status,archived}[] |
This card's sub-cards, in their own sibling order. Non-archived only — an archived sub-card leaves the array and both subCards numbers. Read-only from here: a card is filed under a parent from the CHILD's side (parentId). See Sub-cards. |
dependents |
{id,number,title,status,column,archived}[] |
The reverse direction — cards that are waiting on this one. Read-only; set it from the other card's dependsOn. Deliberately not called blocks (that key is this card's content sections) and it carries no satisfied flag, because doneness there is a fact about the dependent, not about this card. |
commentCount |
number | How many comments the card carries. 0 is explicit. The comments themselves are not on the card — read them from GET /tasks/:id/comments, which is cursor-paginated. Detail only: on the list shape this would be one subquery per card of a board fetch. |
The card does not carry its discussion. GET /tasks/:id returns commentCount and nothing
else about comments — no comments array, no excerpts. A card read whole is therefore not
evidence that a comment you just posted was lost; fetch /tasks/:id/comments to see it. (Before
Sep 2026 there was no comment key at all, and a complete-looking 200 read as "this card has no
discussion" — which is why the count is there now.)
description is kept on both shapes for backward compatibility — it is a plain-text mirror of the
first notes block only. Prefer notes.
Board internals (position, createdById, reminderSentAt, attachment uploader) are never
exposed.
Create / patch body
| Field | Type | Create | Notes |
|---|---|---|---|
title |
string | required | Optional on PATCH. |
description |
string | null | optional | Writes the first notes block as PLAIN TEXT — markup is escaped and the result paragraph-wrapped, so <strong> reaches the operator as literal characters. Send HTML through blocks[].content instead. |
dueDate |
YYYY-MM-DD | null |
optional | Stored at UTC midnight. |
dueTime |
HH:mm | null |
optional | |
remindAt |
ISO date-time | null | optional | The card's push reminder. A true UTC instant, unlike dueDate — send 2026-10-26T14:00:00Z, not a bare date, and convert from the operator's wall clock yourself. Must be in the FUTURE (a past value fires on the next cron pass, which reads as the card moving itself into the due list); on PATCH that is checked only when the value actually CHANGES, so re-posting a card whose reminder has since passed still works. Moving it forward re-arms a reminder that already fired. null clears it. |
status |
enum ScheduleTaskStatus |
optional | |
priority |
enum TaskPriority |
optional | LOW / NORMAL / HIGH / URGENT. |
assignedToId |
id | null | optional | Must be a company member. |
relatedJobId |
id | null | optional | Must be yours. |
relatedCustomerId |
id | null | optional | Must be yours. |
columnId |
id | null | PATCH only | Move target. null detaches the card back to calendar-only. |
column |
string | PATCH only | Move target by name (case-insensitive). |
boardId |
id | PATCH only | Board scope for the two above. |
labelIds |
id[] | PATCH only | Full replacement of the card's labels. [] clears them. |
labels |
string[] | PATCH only | Same, by label name (case-insensitive). Mutually exclusive with labelIds. |
dependsOnIds |
id[] | PATCH only | Full replacement of the cards this one waits for. [] clears them. Max 50. See Dependencies. |
relations |
{taskId,kind}[] |
PATCH only | Full replacement of the typed relations this card owns. [] clears them. Max 50. See Related items. |
checklist |
{id?,text?,done?}[] |
PATCH only | Full replacement of the card's checklist, in order. See The checklist. |
blocks |
{id?,title?,type?,content?,remove?}[] |
PATCH only | Upsert of content blocks, addressed by id or by title. See Content blocks. |
blockOrder |
id[] | PATCH only | Reorder existing blocks — the given ids are renumbered 0..n-1. |
customFields |
{[idOrName]: value|null} |
PATCH only | Sparse merge of the card's custom-field values. See Custom field values. |
parentId |
id | null | optional | File the card under another card. On CREATE it also PLACES the card (the parent's board, first column, the parent's labels). On PATCH, null unlinks it. See Sub-cards. |
archived |
boolean | PATCH only | Archive / restore. See Archive and restore. |
position |
int ≥ 0 | PATCH only | Where the card sits inside its column, 0 = top. Without it every move appends. Out of range clamps to the ends; the column is reindexed 0..n-1, so the value you read back is the value you can send next time. Combinable with a move — it then applies inside the target column. A request that leaves the card calendar-only rejects it (422): there is no column to order it in. |
Duplicate guard
POST /tasks refuses a card that looks like one you already have open, with
409 duplicate_candidates. It is a soft guard: it exists because nothing else in this API
dedupes anything, and an accepted duplicate fails silently — the caller gets a 201 and the
board grows a second copy of work already filed. Agents retry; a retry after a timeout is the
common case, and "another session described the same symptom in other words" is the next one.
{
"error": {
"code": "duplicate_candidates",
"message": "A card with a very similar title already exists. Review the candidates, or repeat the request with ?force=1 to create it anyway.",
"details": {
"candidates": [
{
"id": "clx...",
"number": 142,
"title": "Card dialog: adaptive width",
"status": "PENDING",
"board": "CRM",
"column": "To Do",
"updatedAt": "2026-09-01T10:12:00.000Z",
"score": 0.85
}
],
"recentlyClosed": []
}
}
}
Each candidate carries enough to identify the card, not to read it — fetch it with
GET /tasks/:id (under your own tasks:view scope) if you need its body. score is 0..1
(two decimals): the higher of the two comparisons below, so a title match reads ≥ 0.7 and a
body-only match ≥ 0.4.
Already done is a warning, not a refusal. Cards closed in the last 60 days — in a done
column, archived, or a completed calendar-only task — are scanned too, but they never block:
a 201 then carries them beside the created card, so a client can tell "already filed" (the 409)
from "already done" (link to it, or let the operator decide) — and a 409 lists them too, under
details.recentlyClosed, so one refusal tells the whole story.
{
"data": { "id": "clx...", "number": 143, "title": "Card dialog: adaptive width", "...": "..." },
"meta": {
"recentlyClosed": [
{ "id": "clw...", "number": 131, "title": "Card dialog adaptive width", "status": "COMPLETED",
"board": "CRM", "column": "Done", "updatedAt": "2026-08-30T09:00:00.000Z", "score": 1 }
]
}
}
meta.recentlyClosed is present (possibly empty) whenever the guard ran, and absent on a
?force=1 create — the scan is skipped there, and an absent key must not read as "nothing similar".
What is compared and what is scanned:
- Title — bags of words, case- and punctuation-insensitive, in any script (Cyrillic included).
Reordered words match; one title whose words are wholly contained in another's matches too
(
"Card dialog"vs"Card dialog: adaptive width"), provided the shorter one has at least two words — a single-word title would otherwise match half the board. Threshold0.7. - Title + description — TF-IDF cosine over the title and the first 500 characters of
description(HTML stripped, a light ru/en stemmer), against the stored cards' titles and descriptions. Threshold0.4. This is the arm that catches a paraphrase: three cards filed by three sessions for one flaky test scored ~0.35 by title and 0.42–0.55 by body. A card qualifies through either arm; both thresholds are server constants, not request parameters. - Open cards refuse: not archived,
status: PENDING, and either calendar-only or in a column that is not done (aCANCELEDcolumn is not done, so a cancelled card never blocks the card that replaces it — and is never reported as "already done" either). - The scan is one query over the 500 most recently created cards of the two pools together. This is an advisory gate, not an invariant: a company with a very large backlog can slip an old duplicate past.
- At most 5 candidates and 5 recently-closed cards are listed, best match first.
The same comparison backs the "Similar to T-…" hints the board's quick-add and the New Task dialog show under the title field while you type (≥ 3 words, up to 3 rows, never blocking).
?force=1 bypasses it and creates the card:
curl -X POST 'https://app.donewellapp.com/api/v1/tasks?force=1' \
-H "Authorization: Bearer $DW_KEY" -H 'Content-Type: application/json' \
-d '{"title":"Card dialog: adaptive width"}'
force also accepts true / yes (case-insensitive). Anything else — including 0, false and
an absent parameter — is not a bypass. "Similar title, genuinely different card" is real and
the server cannot tell the two apart, so this flag is how you say which it is; use it deliberately
rather than as a default, or the guard buys nothing.
PATCH /tasks/:id is not guarded — renaming a card into an existing title is allowed.
Moving a card
columnId and column are mutually exclusive — sending both is 422.
column(by name) resolves withinboardIdif given, otherwise within the board the card is already on. A card that is calendar-only and has noboardIdin the body →422 boardId is required to resolve a column by name.columnIdis validated against your company (and againstboardIdwhen supplied).- An unknown column →
422 Column not found. A card that is archived, or belongs to another company →404.
What a successful move does:
Appends the card at the end of the target column — send
positionin the same body to put it somewhere else (0= top).If the card had no column at all and the board has a section template, stamps the board's required sections onto it, so a card filed over the API carries the same body as one filed with the board's "+" button. The untitled block
POST /tasksseeds fromdescriptionis RENAMED into the template's first TEXT section rather than duplicated beside an empty prompt.Only on that FIRST arrival: a card moving from board A to board B already carries A's sections, and stamping B's on top would give it two half-filled bodies. Cards that predate the template are never backfilled either.
blocksin the same body write INTO the stamped sections: one PATCH carrying the column and{"title":"Description",…}leaves exactly one Description, in the template's order, holding your content — the natural "create, then place it with its body" call needs no second round trip. A title outside the template is appended after the stamped ones.Syncs status against the column's done-ness: into a done column →
COMPLETED, out of one →PENDING, same done-ness → untouched. An explicitstatusin the same body wins over this. Done-ness isisDoneexcept in aCANCELEDcolumn, which is never done — moving a card there completes nothing, and moving a completed card there reopens it.Writes a
movedentry in the card's activity feed, attributed to the API key's name (so the team sees "Claude moved this card to Review", not "Someone").
"columnId": null detaches the card (calendar-only) and deliberately does not change status —
leaving a board says nothing about completion.
Setting labels
labelIds / labels replace the card's whole label set — they never merge. Sending []
clears every label; omitting the field leaves the labels untouched. Merging would make
un-labelling impossible over the API, since there is no "remove" verb.
Labels live on a board (TaskLabel.boardId), so:
- A calendar-only card cannot carry labels →
422, never a silent no-op. - The board checked against is the one the card ends up on after THIS request. One PATCH can
both move a card to another board and set its labels; validating against the board it is leaving
would let a card wear a label from a board it no longer belongs to. Consequence worth planning
for:
{"columnId": "<column of board B>", "labelIds": ["<label of board A>"]}is422, and the move is rejected with it — the whole PATCH is atomic on validation. - A label from another board of your company, or from another company, is
422 validation_failed(not404— the body is what is wrong, andGET /boards/:idalready lists a board's labels, so there is no existence to hide).labelsresolves names within that same board; an unknown name is422, never a silent skip — the opposite of the filter rule, where an unresolved name yields an empty list. - A change writes a
labels_changedentry to the card's activity feed, attributed to the API key's name. Re-sending the labels a card already has writes nothing.
POST /tasks takes no labels: it creates a calendar-only card, which has no board yet. Set them
with the same PATCH that places the card in a column.
Dependencies
A card can declare that it is waiting for other cards. The edge is a stored relation, so it survives a rename, is readable in one request, and cannot be talked out of by editing prose.
dependsOn (both DTOs) lists what this card waits for:
| Field | Notes |
|---|---|
id, number, title, status |
The blocker's own identity, so you never need a second request to say what a card is waiting for. |
column |
{id,name,isDone,category} | null — null on a calendar-only card. |
archived |
The blocker has been archived. |
satisfied |
This dependency no longer holds the card back. Read this rather than deriving it. |
blockedBy is the count of entries with satisfied: false — zero means the card is free to start.
satisfied is true when the blocker sits in a done column that is not a CANCELED one,
when the blocker is archived, or — for a card on no board — when its own status is
COMPLETED. Two of those are deliberate calls worth knowing:
- An archived blocker stops blocking. An archived card is hidden from the board, from
GET /tasksand from the app entirely, so treating it as an eternal blocker would strand its dependent forever with no symptom anywhere. The edge row survives — restore the blocker and the dependent is blocked again. - A
CANCELEDcolumn never satisfies, even withisDoneset. TickingisDoneon a "Cancelled" column is the only way to get status sync there, so companies that use one have it set; a cancelled blocker is fully visible and the operator can drop the edge deliberately.
Write with dependsOnIds, which replaces the whole set (the labelIds precedent — PATCH has
no second verb, so merging would make un-blocking impossible):
curl -X PATCH https://donewellapp.com/api/v1/tasks/<id> \
-H "Authorization: Bearer $DW_KEY" -H 'Content-Type: application/json' \
-d '{"dependsOnIds":["<id of the card it waits for>"]}'
Refusals, and they differ on purpose:
- an id that is not this company's, or does not exist →
404 not_found. Every other door in this API answers 404 for a task id that is not yours; a different code here would be the one place separating "well-formed but not yours" from "gone". - the card's own id, or an edge that would close a cycle (
A → B → C → A, at any depth) →422 validation_failed, naming the id that closed the loop. A cycle is refused at the door because nothing downstream could ever surface it: both cards would simply read "blocked" forever, indistinguishable from an honest pending dependency.
Like every other element of a PATCH, a refused dependency leaves the card completely
untouched — a title sent in the same body does not land either.
Setting dependencies writes a dependencies_changed entry to the card's history, attributed to the
API key's name.
Related items
Beside blocking, a card can carry typed relations to other cards: RELATES (symmetric — "these
two belong together") and DUPLICATES (directed — "this card is a copy of that one"). Blocking is
deliberately not a kind here: it has its own cycle rule, its own satisfied predicate and its
own dependsOnIds field, and a second copy of it would drift. Parent/child is parentId.
relations (detail only) lists both directions in one array — a relation written on the
other card shows up here with nobody touching this one:
| Field | Notes |
|---|---|
id |
The relation row. |
kind |
RELATES | DUPLICATES. |
direction |
outgoing (this card is the stored from end) or incoming. |
editable |
May this card replace the row? True for every RELATES row and for an outgoing DUPLICATES one; an incoming DUPLICATES row belongs to the other card. |
label |
How the row reads on THIS card — Relates to / Duplicates / Duplicated by. |
task |
{id,number,title,boardId,columnName,archived} — the counterpart's own identity, and its own boardId: a relation may cross boards, so a link built from the card you are reading would point at the wrong one. |
Rows whose counterpart is archived are shipped with archived: true rather than filtered —
same call as dependents, so an integration decides for itself. (The app's own panel hides an
archived counterpart only in the groups it cannot edit.)
Write with relations, which replaces the set this card owns — its outgoing DUPLICATES rows
and every RELATES row touching it. An incoming DUPLICATES row is left alone: it belongs to
the other card, and sweeping it would delete a link its owner created.
curl -X PATCH https://donewellapp.com/api/v1/tasks/<id> \
-H "Authorization: Bearer $DW_KEY" -H 'Content-Type: application/json' \
-d '{"relations":[{"taskId":"<other card>","kind":"RELATES"}]}'
A symmetric relation is one row whichever end writes it: re-asserting A ↔ B from B finds the
existing row instead of mirroring it, and either end can drop it.
Refusals, and they mirror the dependency ones:
- an id that is not this company's, or does not exist →
404 not_found. - the card's own id, an unknown
kind, or the reverse duplicate (B already claims "B duplicates A" and you send "A duplicates B") →422 validation_failed. The pair would render on both cards as a contradiction no reader can resolve, and neither card owns both rows to fix it.
There is deliberately no cycle check: nothing walks these edges transitively, so a ring of
RELATES has no failure mode.
Like every other element of a PATCH, a refused relation leaves the card completely untouched.
Setting relations writes a relations_changed entry to the card's history, attributed to the API
key's name.
Sub-cards
A card can hang under another card. One parent, at most three levels (root → child → grandchild), at most 100 live sub-cards per parent, and always on the same board.
Read it on both DTOs:
| Field | Notes |
|---|---|
parentId |
The card this one hangs under, null for a root card. |
parent |
{id,number,title,archived} | null — the parent's own identity, so a breadcrumb needs no second request. archived: true means the parent was put away; the LINK survives, and restoring it brings the breadcrumb back. |
subCards |
{done,total} over this card's non-archived sub-cards. done counts a sub-card sitting in a done column that is not a CANCELED one, or — for a card on no board — one whose status is COMPLETED. |
children |
Detail only. The sub-cards themselves, in sibling order. |
Progress is a number, never an automation. Finishing every sub-card does not move the parent, and moving the parent does not touch its sub-cards. Nothing in this API changes a card's status because of another card's.
Write it from the CHILD's side:
# file a card under T-150
curl -X PATCH https://donewellapp.com/api/v1/tasks/<child> \
-H "Authorization: Bearer $DW_KEY" -H 'Content-Type: application/json' \
-d '{"parentId":"<parent id>"}'
# and back out again
curl -X PATCH https://donewellapp.com/api/v1/tasks/<child> \
-H "Authorization: Bearer $DW_KEY" -H 'Content-Type: application/json' \
-d '{"parentId":null}'
POST /tasks accepts parentId too, and it is the one create field that places the card: it
lands on the parent's board, in that board's first column, carrying a copy of the parent's
labels and appended last among its siblings. (A plain POST still creates a calendar-only card.)
The labels are a snapshot, not a live link — re-labelling the parent later does not re-label its
sub-cards.
Refusals, and each is its own code so an integration can tell them apart. All are 422 except the
first:
| Code | When |
|---|---|
404 not_found |
The parent id is not this company's, or does not exist. Every other door here answers 404 for a task id that is not yours. |
hierarchy_cycle |
The card itself, or one of its own sub-cards at any depth — archived ones included, because archiving hides a card without cutting the link. Nothing downstream could ever surface a stored loop: both cards would simply render breadcrumbs into each other. |
hierarchy_depth |
The link would make a fourth level. Counted from BOTH ends — a card that already has sub-cards of its own cannot move under a card that has a parent. |
hierarchy_limit |
The parent already has 100 live sub-cards. Archiving one frees its slot. |
hierarchy_board |
The parent is on another board, or either card is calendar-only (no board). |
Every message names the card number(s) involved, and it is the same sentence the app shows its own operators — one resolver behind both doors.
Two consequences worth planning for:
- A card that changes board loses its family. Moving a card to another board (or restoring it
onto one) clears its own
parentIdand its sub-cards' — the sub-cards stay exactly where they are, as roots. Re-parenting them onto the new board would drag cards nobody touched across. - Deleting a parent promotes its sub-cards, never deletes them (
DELETE /tasks/:id): they become root cards on the same board.
GET /tasks?parentId=<id> lists one card's sub-cards; ?parentId=none lists the roots. An id that
resolves to nothing narrows to nothing, like every other filter here.
Both writes record a card-history entry: parent_changed on the child (naming the card numbers it
left and joined), subtask_added on the parent when a sub-card is created under it.
The checklist
checklist replaces the card's whole checklist, in the order you send it:
- an element with a known
idis updated in place (its id survives, so you can address it again next time).textanddoneare both optional there —{"id": "…", "done": true}ticks an item without re-sending its text; - an element without an
idis created (textis then required); - a stored item absent from the array is deleted.
[]clears the checklist.
That is the only shape in which "remove this item" and "reorder" are expressible at all — PATCH has no second verb. So read the card first: sending one item ticks that item and deletes the rest.
Order is the array order (positions are renumbered per section). position is not accepted on
the wire — it would be a value the server silently overrides.
Items live in a CHECKLIST section (blocks), and the card gets one automatically when it has
none. New items land in the card's first checklist section; an existing item stays in its own,
so a card with two checklists keeps them apart.
Each change writes the same activity entries the app writes one click at a time —
checklist_add / checklist_toggle / checklist_remove, attributed to the API key's name.
An id that is not on this card is 422, never a silent create under a new id.
curl -s -X PATCH "$DW_BASE/tasks/$TASK" -H "Authorization: Bearer $DW_KEY" \
-H 'Content-Type: application/json' \
-d '{"checklist":[{"text":"Write the test"},{"text":"Make it pass","done":true}]}'
Content blocks
A card is a list of sections — TEXT (notes), CHECKLIST and ATTACHMENTS — each with an
operator-chosen heading. GET /tasks/:id returns them as blocks, and blocks on PATCH writes
them.
Addressing. id first, else title (case-insensitive, trimmed). The title arm is the point:
an agent knows a section is called "Acceptance Criteria" but not its cuid. A title matching several
sections resolves to the first in board order; a title matching none creates the section at the
end.
A
titleis an address, not a rename.{"title":"acceptance CRITERIA","content":"…"}writes into that section and leaves its heading exactly as the human typed it. To rename, address the block byidand send the newtitle:{"id":"…","title":"Acceptance Criteria"}.
Upsert, never replacement. A section you do not mention is left alone — unlike labelIds and
checklist. Removing a section cascades away its checklist items and attachment rows, so it has to
be said out loud:
| Field | Notes |
|---|---|
id |
The section to write into. |
title |
Address (or, with an id, the new heading). "" resets the heading to the per-type default label. |
type |
TEXT (default) / CHECKLIST / ATTACHMENTS. Only meaningful when CREATING — a stored section's type is fixed, and a mismatch is 422 rather than a silent retype. |
content |
Sanitized HTML, or plain text (which is paragraph-wrapped for you). Only a TEXT section holds content; sending it for another type is 422. |
remove |
true deletes the section. An unresolved remove is 422 — a silent no-op would leave you believing a section is gone. |
blockOrder reorders existing sections: send their ids and they are renumbered 0..n-1. Ids
only — a section created by the same request does not have one yet. A block id belonging to another
card is 422 and reorders nothing.
description still writes the first TEXT section and keeps working exactly as before. It is
applied before any blocks/blockOrder in the same request, so it always means "the card's
opening note as it stood when the request arrived".
# Fill in a named section without knowing its id — creating it if the card has none.
curl -s -X PATCH "$DW_BASE/tasks/$TASK" -H "Authorization: Bearer $DW_KEY" \
-H 'Content-Type: application/json' \
-d '{"blocks":[{"title":"Acceptance Criteria","content":"<p>It ships.</p>"}]}'
Custom field values
customFields is a sparse merge keyed by the field's id or its name
(case-insensitive): {"Est. hours": 4, "Mode": "Ship it"}. Only the fields you name are touched,
and null clears one. It is deliberately not a replacement — fields are independent answers, not a
set, so a full replace would blank a field a human filled in between two of your calls.
Definitions come from the board (GET /boards/:id → customFields) and are never changed by
writing a value; managing them is Custom fields under Boards.
Every value is validated against its definition and a bad one is 422 with the reason — this
is where the API deliberately differs from the app, whose form can only post shapes it rendered and
therefore drops anything else silently:
| Type | Accepts |
|---|---|
text / url |
a string (≤500 / ≤2048 chars). |
number |
a JSON number. A numeric string is 422 — it renders fine and breaks the next reader that sums or compares it. |
date |
YYYY-MM-DD. |
checkbox |
true / false. The string "true" is 422. |
select |
an option name (case-insensitive) or its stored id. Anything else is 422 — the point of a select is the closed list. An archived option is refused on write, though a card that already holds one keeps reading it back. |
A select is stored as the option id, so renaming the option later keeps every answered card
readable; GET reports the option's current name.
The card must be on a board (custom fields are per-board) — a calendar-only card is 422. When one
PATCH both moves the card and sets values, they are validated against the board the card ends up
on, same rule as labels.
Archive and restore
archived: true puts the card away; archived: false brings it back. Archiving keeps the card's
column and position, which is what lets restore return it where the app would.
- Restore appends the card to the end of its own column. If that column was deleted while
the card sat in the archive, pass
boardIdin the same body and it falls back to that board's first column; with no board context it stays calendar-only. - An explicit
columnId/columnin the same request wins over that placement (and its status sync applies as usual) — "restore it straight into Review" is one call. - Both directions are idempotent: re-archiving an archived card does not re-stamp its archive time or add a second history entry.
- Each writes an
archived/restoredentry to the card feed under the API key's name.
An archived card is otherwise read-only. A PATCH that does not carry
archivedanswers404on one — the same answer as a foreign id, so nothing is leaked by trying.GET /tasks/:idstill resolves it (witharchived: true), andGET /tasksnever lists it.
Comments
The card's discussion thread. Comments written over the API carry authorId: null and
authorName = the API key's name, and they notify the card's assignee and creator exactly like
a comment posted in the app.
GETreturns newest first (cursor-paginated like every list) — reverse a page to read the thread chronologically. Comments on archived cards are readable.POSTtakes{ "body": "…" }(1–5000 chars) and returns201with the created comment. Commenting on an archived card is404— the card is read-only.- There is no PUT/DELETE: editing or removing a comment is an authorship action, and a key has no author identity.
- This endpoint is the only place comments live. The card DTO carries
commentCountand never the thread, so "did my comment land" is answered here, not by re-reading the card. bodymay carry mention tokens (@[Ada Lovelace](u:clx_user_id)), which notify the mentioned user instead of sending them the generic comment notification — same as a mention typed in the app. Mentions of users who can't see a private board are dropped.
Response: id, authorId, authorName, body, bodyHtml, attachments[]
({id,fileName,mimeType,fileSize,url}), editedAt, createdAt. bodyHtml is sanitized rich
HTML (null on old plain-text rows); comment attachment urls are signed exactly like card
attachments (see "Attachment URLs").
- POST with files: send
multipart/form-datainstead of JSON — abodytext field (optional when files are present) plus one or morefilesfields (≤6). Files-only comments are legal. JSON{ "body": "…" }keeps working unchanged.
Activity
GET /api/v1/tasks/:id/activity — the card's history feed (created / moved / updated /
completed / …), newest first, cursor-paginated. Rows: id, actorName (a person's name or an
API key's), type (plain string — tolerate unknown values), data (per-type payload of
already-rendered strings, e.g. {"changes":[{"field":"title","from":"A","to":"B"}]} for
updated, {"from":"Ready","to":"Done"} for moved), createdAt. Readable on archived cards.
What tasks deliberately do not expose
No comment editing or deletion. /tasks/:id/comments has GET and POST and will not be
given PATCH/DELETE, and the reason is in the schema rather than in caution.
A comment written by a key carries authorId: null — but so does a comment written by a PERSON
whose account was later deleted (TaskComment.authorId is onDelete: SetNull), with that
person's name still frozen in authorName. So the rule you would want — "a key may edit only what
keys wrote" — is not expressible from the data at all, and the nearest rule that IS expressible
lets an integration rewrite a departed colleague's words in the card feed, with editedAt as the
only trace. Correcting yourself is a new comment; the thread is append-only, like the activity feed
beside it.
Deleting an ATTACHMENT is open (above): TaskAttachment.uploadedById is SetNull in the same way,
but removing a file is housekeeping you can undo by re-uploading, not putting words in someone's
mouth.
Attachment upload
POST /api/v1/tasks/:id/attachments — multipart/form-data, one or more files fields (≤10 per
call). Files run the same server pipeline as UI uploads (images re-encoded, documents magic-byte
checked; svg/html rejected) and land in the card's last Attachments block, created when none
exists (the UI's Ctrl+V rule). Response 201: { "uploaded": [{id,fileName,mimeType,fileSize,url}], "skipped": [{fileName,error}] } — per-file resilient; all-failed → 422. Each upload writes an
attachment_add activity entry attributed to the key.
DELETE /api/v1/tasks/:id/attachments/:attachmentId → { "deleted": true }, and an
attachment_remove activity entry. Scope is tasks:edit, not tasks:delete — the app gates the
same act on tasks.edit, and tasks:delete is about deleting the CARD. The URL is checked against
BOTH ids, so an attachment of another card answers 404. The file on disk is deliberately left in
place (the same rule the in-app delete follows); the row is what stops referencing it. A signed URL
handed out before the delete keeps working until its exp (at most an hour).
Attachment URLs
attachments[].url (on the card, on comments, and in the attachment-upload response) is a
signed, site-absolute path —
/uploads/tasks/<taskId>/<file>?exp=<unix seconds>&sig=<base64url> — not a full URL. Prefix your
instance origin (https://donewellapp.com/uploads/…) and fetch it as is: no Authorization header
is needed, the signature stands in for one.
- A signature opens exactly that path and expires one hour after the response that carried
it. Re-read the card (or the comment list) for fresh URLs — do not store them, and do not cache
them past
exp. It is not revocable before then; treat it like the file itself. - The bare path (without
?exp=&sig=) is not world-readable: task files are served only to a signed-in session of the owning company that may open the card's board (a private board's files are invisible to the rest of the company), with the Tasks module on andtasks.viewgranted. A request that does not qualify gets404, never403. - Inline images inside block HTML (
blocks[].content) and commentbodyHtmlkeep their bare/uploads/tasks/…src, deliberately unsigned: HTML youPATCHback is stored, and a signedsrcwould persist an expiring query string in the card. Such an image renders for a signed-in board member in the app; an API client that needs its bytes cannot fetch it with a key alone.
Changed in T-675 (Oct 2026). These URLs used to be bare and served without any auth — a
capability nobody could withdraw. A link captured before the change now answers 404; re-read
the card for a signed one.
Boards
tasks scope — the settings of a kanban board: its columns, its labels, and its custom-field
definitions. The cards themselves are Tasks; a board's own record is read-only here.
Three things to know before you start:
- A board can be created and configured, never deleted, and never re-shared. See What boards deliberately do not expose — those two refusals are decisions, not gaps, and each has a reason worth reading before you work around it.
- A key reaches every board of its company, private ones included. Board sharing
(
visibility: "PRIVATE", member and role grants) governs people — app sessions and user tokens — not API keys; see Trust model. A board id from another company returns404, never403. - Scopes map to REST verbs: read →
tasks:view, create →tasks:create, update →tasks:edit, delete →tasks:delete. (In the app, editing custom fields is onetasks.editaction; over the API each verb keeps its own scope.) Everything here fits inside the existingtasksscopes — there is no board-only capability and no new scope to grant.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/boards |
tasks:view |
| POST | /api/v1/boards |
tasks:create |
| GET | /api/v1/boards/:id |
tasks:view |
| PATCH | /api/v1/boards/:id |
tasks:edit |
| GET | /api/v1/boards/:id/access |
tasks:view |
| POST | /api/v1/boards/:id/columns |
tasks:create |
| PATCH | /api/v1/boards/:id/columns/:columnId |
tasks:edit |
| DELETE | /api/v1/boards/:id/columns/:columnId |
tasks:delete |
| POST | /api/v1/boards/:id/labels |
tasks:create |
| PATCH | /api/v1/boards/:id/labels/:labelId |
tasks:edit |
| DELETE | /api/v1/boards/:id/labels/:labelId |
tasks:delete |
| POST | /api/v1/boards/:id/fields |
tasks:create |
| PATCH | /api/v1/boards/:id/fields/:fieldId |
tasks:edit |
| DELETE | /api/v1/boards/:id/fields/:fieldId |
tasks:delete |
Every nested id is validated against both the board in the path and your company — a column of
another board (or another tenant) is 404 not_found.
Response shapes
The list DTO (GET /boards, ordered by the operator's own board order):
| Field | Type | Notes |
|---|---|---|
id |
string | |
name |
string | |
visibility |
string | PUBLIC / PRIVATE. Informational — it does not restrict your key. |
position |
int | The board's place in the app's board switcher. |
autoArchiveDoneAfterDays |
int | null | null = auto-archive off. |
createdAt |
ISO date-time |
The detail DTO (GET /boards/:id) is the list DTO plus:
| Field | Type | Notes |
|---|---|---|
columns |
{id,name,position,color,isDone,category,requiresComplete}[] |
Ordered by position. |
labels |
{id,name,color,position}[] |
Ordered by position. |
customFields |
{id,name,type,position}[] |
Definitions only; a card's values come with GET /tasks and GET /tasks/:id. |
fieldSettings |
{properties:[{key,hidden,pinned,required}], tile:{cover, elements:[{key,hidden}]}} |
The display lists, resolved — see below. |
sectionTemplate |
{id,title,type,visible,required,placeholder}[] |
The card body's named sections, in order. [] = the board has none. |
The two settings blocks come back resolved, i.e. exactly what the app renders and not the raw
stored blob: keys that no longer exist are already dropped, everything the board knows about is
already listed, status/assignee are already un-hidden and the tile is already narrowed to a
subset of the card. That is the point of reading them — compare this against what you want and
PATCH only the differences, instead of writing a full list blind over somebody else's row order.
The output of GET is directly re-postable to PATCH; doing so is a no-op.
The board's own record is created and edited here; its sharing is not.
The board record
Create (POST /boards) takes one field, name (1–100 chars), and returns the detail DTO
(201). The board is appended to the end of the operator's board order and seeded with the same
three columns the app seeds — To Do (UNSTARTED), In Progress (STARTED), Done
(COMPLETED, isDone: true) — so status sync and the auto-archive work from the first card.
It is always PUBLIC, and it has no creator. A key is not a person: a PRIVATE board created here
would carry no member rows, so nobody but a company admin could open it in the app — a board your
integration can see and the team that owns it cannot.
Patch (PATCH /boards/:id), all fields optional, empty body → 422:
| Field | Type | Notes |
|---|---|---|
name |
string (1–100) | Trimmed. |
autoArchiveDoneAfterDays |
int (1–365) | null | Explicit null turns the nightly auto-archive off; omitting the key leaves the stored value alone. The sweep only touches columns whose category is COMPLETED. |
fieldSettings |
object | The ordered display lists — see below. |
sectionTemplate |
array | The card body's named sections — see below. [] clears the template. |
fieldSettings carries properties (the card-dialog rows) and tile ({cover, elements} — the
board tile). Each list is [{key, hidden?}] in display order, where a key is a built-in
(status, assignee, dueDate, priority, labels, reminder, customer, job; the tile also
has number, description, checklist, attachments, comments) or cf:<fieldId> for a custom
field. Omit a list to leave it as it is; unknown keys are inert (the reader drops what no longer
exists), and anything the board knows about but your list omits is appended.
A card row may also carry pinned: true — "show this row even when it is empty", the escape
hatch from the card dialog's populated-only default — and required: true, "this row must carry
an answer", which is what a requiresComplete column checks before it will accept the card. Both
are CARD-row flags only: the tile has no such rule, so sending either inside tile.elements is
422. required is refused on a hidden row, on an always-shown row (status, assignee) and on a
checkbox custom field — "required" for a yes/no field could only mean "must be ticked".
Both flags come back from GET, and re-posting that list unchanged is the no-op it looks like. The
list is written WHOLESALE, so a caller that strips the flags out of what it read un-pins and
un-requires every row on the board.
Two rules are re-applied server-side whatever you send, because the app's switches for them are
disabled rather than absent: status and assignee can be reordered but never hidden, and the
tile is a subset of the card — an element hidden on the card cannot print on the tile.
Two more are refusals (422), mirroring the app's own pin action, because a stored flag that
decides nothing reports success for a pin the operator will never see: pinned on status or
assignee (already always visible), and pinned together with hidden on the same row.
labels is an ordinary row since T-528 (reverting the T-378 header move) — its pinned round-trips
like any other row's. An unknown KEY is not one of them — this endpoint writes the whole list, whose contract is that the
reader drops what no longer exists, so a list round-tripped after a field was deleted still saves.
The section template
sectionTemplate is the board's card-body shape — the Azure DevOps work-item idea: Description,
Acceptance Criteria, Technical Design, RCA. Each entry is
{id?, title, type, visible?, required?, placeholder?} where type is TEXT / CHECKLIST /
ATTACHMENTS and placeholder REPLACES the prompt of an empty section.
The two flags are independent and both default to false:
| Flag | Means |
|---|---|
visible: true |
The section is on every new card, even empty. Filling it is optional. |
required: true |
The section must carry an answer — this is what a requiresComplete column checks. It implies visible (a demand nobody can see is unmeetable). |
| neither | The section is OFFERED under "+ Section" and is not stamped onto new cards. |
Sending required alone still means a real requirement: the endpoint fills visible in for you.
(Before Sep 2026 a section carried one flag named required that in fact meant visibility; stored
templates written then keep that reading, which is why GET always hands you both keys and why
posting back what you read is the safe way to edit one of them.)
id is optional on the wire and minted when absent — but a PATCH that RENAMES a section should
send back the ids from GET /boards/:id: the id is the row's identity in the app's editor, and
re-minting turns a rename into a delete plus an add. Two sections of the same name collapse (both
would claim the same block, so the second could never be filled). Send [] to clear the template.
It is its OWN key, deliberately not a member of
fieldSettings, and the distinction is not cosmetic: that blob is written WHOLESALE and its reader strips unknown keys, so a template parked inside it is erased by the next Fields save — silently, and only on the boards somebody happened to touch afterwards.
Setting a template does not touch cards that already exist. It applies to cards created afterwards: the board's "+" button stamps the required sections, and so does the API the first time a card LANDS in one of the board's columns (see Moving a card) — before that change, a card filed by an integration was permanently section-less next to human-created neighbours.
fieldSettingsdeliberately does not carry the custom-field DEFINITIONS. Those have their own endpoints (Custom fields), and the settings blob is stored wholesale — so accepting them in two places is how a client that knows about one of them wipes the other.
Board access (read-only)
GET /boards/:id/access → { visibility, members: [{userId}], roleGrants: [{roleId}] }.
Who can see this board in the app. Reading it widens nothing — your key already reaches every
board of the company — so this only tells you which people would find the card you just filed. Both
lists stay populated on a PUBLIC board, where the app ignores them: they are the rows a switch
back to PRIVATE would restore.
Ids only, deliberately: names and emails would be a wider disclosure about PEOPLE than anything
else this API returns (a task exposes assignedToId, never a name).
What boards deliberately do not expose
These are decisions, recorded here so that the next person to hit one knows it was considered.
No board DELETE. Deleting a board deletes its columns, and every card on it degrades to calendar-only — the work is not lost, but it leaves the board it was organised on and nothing puts it back. That is a destructive, unreviewable action to hand to an automation; a human does it in the app. (Board CREATE is open — inventing a lane is recoverable.)
No writing the board's sharing — visibility, members, role grants. Sending visibility to
POST /boards or PATCH /boards/:id is 422 Board visibility and sharing are managed in the app, not over the API, an explicit refusal rather than a silent unknown-field rejection.
Two reasons, and the first is the strong one. In the app, visibility is not a column you set — it is
owned by one action that rewrites the board's member and role-grant rows in the same transaction,
and that action is gated on board creator or company admin, strictly more than the tasks.edit
your tasks:edit scope maps onto. Opening it would let a key do what the equivalent USER cannot.
And a board flipped to PRIVATE by a key would have no member rows to keep: it would vanish from
the app for everyone while your key went on reading it happily.
READING the sharing list is open — see Board access. If your integration genuinely needs to change it, that is a product decision to take deliberately (with an audit trail), not a gap to patch.
Columns
A column is one board lane. isDone marks the lane that means "finished" — moving a card into it
completes the card (see Moving a card). category is the closed vocabulary the
lane's NAME maps onto: BACKLOG | UNSTARTED | STARTED | COMPLETED | CANCELED. The two are
independent, with one override: a CANCELED column is never treated as done, whatever isDone
says, and the nightly auto-archive sweeps COMPLETED columns only.
Create (POST /boards/:id/columns) — appended to the end of the board:
| Field | Type | Create | Notes |
|---|---|---|---|
name |
string (1–60) | required | Trimmed. |
color |
string (≤30) | null | optional | An accent key (gray, blue, green, amber, red, purple); an unknown key just renders without an accent. ""/null = none. |
isDone |
boolean | optional | Defaults to false. |
category |
enum | optional | BACKLOG | UNSTARTED | STARTED | COMPLETED | CANCELED. Omitted → derived from isDone (true → COMPLETED, else UNSTARTED), so a client that predates the field still creates a column the auto-archive sweeps. |
requiresComplete |
boolean | optional | Defaults to false. Entering this column demands the board's REQUIRED sections and properties carry an answer — see the note below. On a patch, omitting it keeps the stored value. |
Patch (PATCH /boards/:id/columns/:columnId) takes the same fields, all optional — an empty
body is 422. Only the keys you send are written (omitting color never clears it, and omitting
category never re-derives it from isDone — on a patch the two stay independent).
It additionally takes position (int ≥ 0): a move-to-index, not a stored number. The
board's columns are reordered around it and renumbered 0..n-1, so the position you read back is
the one you can send next time; an index past the end clamps to last. (Writing the number straight
onto the row would look the same and be wrong — position is not unique, so two columns would tie
and the addressable indexes would drift.)
Toggling
isDoneorcategorydoes not retroactively restatus the cards already in the column — only a card move syncs status. Same rule as the in-app column editor.
requiresComplete — the entry gate. A board can mark sections (sectionTemplate) and property
rows as REQUIRED; a column with this flag refuses a card that leaves any of them empty. It applies
to ENTRY only, so moving a card OUT of such a column, reordering inside it, and creating a card
directly in it are all allowed — a new card is empty by definition, and gating creation would make
the column unusable. PATCH /tasks/:id answers 422 validation_failed with a message naming what
is missing ("“Ready” needs Acceptance Criteria and Due date filled in first.") — the same string
the app shows when the card is dragged, and the same for columnId and column-by-name. A single
PATCH may fill the requirement and move in one call: the gate reads the state your request LEAVES,
and a block you create in that body is matched to its section by title. Empty means: a TEXT section
whose HTML has no text and no image, a checklist section with no items, an attachments section with
no files, and a property with no value (a NORMAL priority does not count — it is the default every
card is born with).
Delete (DELETE /boards/:id/columns/:columnId) — the cards are relocated, never deleted:
- They move to the lowest-position remaining column of the same board, appended after that column's existing cards (so deleting a column doubles as "merge into the first one").
- If it was the board's last column, they detach to calendar-only (
board/columnbecomenullon the task DTO; they still show on/schedule). - Statuses are not changed — relocation is bookkeeping, not completion. Archived cards relocate too and stay archived.
Response: { "data": { "deleted": true, "relocatedToColumnId": "clx…" } } —
relocatedToColumnId is null when the cards were detached.
Labels
| Field | Type | Create | Notes |
|---|---|---|---|
name |
string (1–40) | required | Unique per board (case-sensitive). |
color |
string | required | A palette key — green, yellow, orange, red, purple, blue, sky, lime, pink, gray, teal, cyan, indigo, violet, fuchsia, rose, amber, emerald, brown, charcoal — or a 6-digit hex (#3b82f6). Anything else is 422. |
PATCH takes both fields optional (empty body → 422), plus position — the same
move-to-index as a column, over the board's label order. New labels are appended
(position = max+1).
A name already used on that board returns 409 duplicate, not a 500 and not a silent rename.
Delete removes the label from every card on the board (the card↔label rows cascade).
Response { "data": { "deleted": true } }.
Custom fields
Per-board extra fields on a card. type is one of text, number, date, checkbox, url,
select. A board holds at most 20 definitions; the 21st is 422.
| Field | Type | Create | Notes |
|---|---|---|---|
name |
string (1–40) | required | Trimmed. |
type |
enum | required | text / number / date / checkbox / url / select. |
options |
{id?,name,color?,archived?}[] |
optional | The choices of a select. Kept for any type (retyping away and back does not lose them). ≤20 live + ≤40 total. |
select options. name is 1–40 chars; color is a palette key (green, sky, …) or
#rrggbb; archived: true retires a choice without deleting it. id is minted by the server when
you omit it — a PATCH that renames choices MUST send their existing ids back, because a card
stores the option id: re-minting them turns every answered card into an unresolvable value. Omitting
options on PATCH preserves the stored list; sending an array replaces it wholesale.
A field DTO carries options: [{id, name, color, archived}] (empty for non-select types), archived
ones included — that is what keeps an old card's value explainable.
position is not accepted on either verb — definitions are appended and renumbered 0..n-1 by the
server. New fields appear at the end of the card dialog and start hidden on the board tile,
exactly like one added in the app.
Delete removes the definition only. Cards keep their stored values for that id — they simply
stop resolving, so re-adding a field with the same id brings them back. Response
{ "data": { "deleted": true } }.
Custom-field definitions live in one JSON column on the board, so each write is a read-modify-write. Two concurrent calls against the same board can lose one another's edit — configure a board serially.
Example: an agent sets up its own lane
# 1. Which boards exist? (…or make one: POST /boards with {"name":"Ops"} → a PUBLIC board
# seeded To Do / In Progress / Done.)
curl -s "$DW_BASE/boards" -H "Authorization: Bearer $DW_KEY"
# → { "data": [ { "id": "clx_board_id", "name": "Ops", "visibility": "PUBLIC", "position": 0, ... } ], ... }
# 2. Full settings of one board.
curl -s "$DW_BASE/boards/clx_board_id" -H "Authorization: Bearer $DW_KEY"
# → { "data": { "columns": [...], "labels": [...], "customFields": [...] } }
# 3. Add a column at the end.
curl -s -X POST "$DW_BASE/boards/clx_board_id/columns" \
-H "Authorization: Bearer $DW_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Needs input", "color": "amber" }'
# → 201 { "data": { "id": "clx_col_id", "name": "Needs input", "position": 3, "isDone": false,
# "category": "UNSTARTED" } }
# 4. Add a label (a duplicate name would be 409 duplicate).
curl -s -X POST "$DW_BASE/boards/clx_board_id/labels" \
-H "Authorization: Bearer $DW_KEY" -H "Content-Type: application/json" \
-d '{ "name": "agent", "color": "#7c3aed" }'
# 5. Add a custom field the agent will fill in per card.
curl -s -X POST "$DW_BASE/boards/clx_board_id/fields" \
-H "Authorization: Bearer $DW_KEY" -H "Content-Type: application/json" \
-d '{ "name": "PR link", "type": "url" }'
# → 201 { "data": { "id": "0b0f…-uuid", "name": "PR link", "type": "url", "position": 2, "options": [] } }
# 5b. …or a strict list of choices instead of free text.
curl -s -X POST "$DW_BASE/boards/clx_board_id/fields" \
-H "Authorization: Bearer $DW_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Mode", "type": "select",
"options": [ { "name": "Implement", "color": "green" },
{ "name": "Plan only", "color": "sky" },
{ "name": "Research", "color": "purple" } ] }'
# → 201 { "data": { …, "options": [ { "id": "…", "name": "Implement", "color": "green", "archived": false }, … ] } }
# 5c. Move the lane to the front, and stop auto-archiving finished cards on this board.
curl -s -X PATCH "$DW_BASE/boards/clx_board_id/columns/clx_col_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" -d '{ "position": 0 }'
curl -s -X PATCH "$DW_BASE/boards/clx_board_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" -d '{ "autoArchiveDoneAfterDays": null }'
# 5d. Give the board a card-body shape, and pin one row so it shows on empty cards.
# Read first, then write only what differs — GET /boards/:id carries both blocks.
curl -s -X PATCH "$DW_BASE/boards/clx_board_id" \
-H "Authorization: Bearer $DW_KEY" -H "Content-Type: application/json" -d '{
"sectionTemplate": [
{ "title": "Description", "type": "TEXT", "required": true },
{ "title": "Acceptance Criteria","type": "TEXT", "required": true },
{ "title": "RCA", "type": "TEXT", "placeholder": "What actually broke?" }
],
"fieldSettings": { "properties": [ { "key": "dueDate", "pinned": true } ] }
}'
# → detail DTO; from now on a card ARRIVING in one of this board's columns carries
# "Description" and "Acceptance Criteria" — including cards your key files itself.
# 6. Retire the lane again — its cards merge into the board's first column.
curl -s -X DELETE "$DW_BASE/boards/clx_board_id/columns/clx_col_id" \
-H "Authorization: Bearer $DW_KEY"
# → { "data": { "deleted": true, "relocatedToColumnId": "clx_first_col_id" } }
Visits
visits scope (Sep 2026, the mobile app's "Today" screen and the technician's day; the team
schedule and the edit writes Oct 2026). Reads need visits:view (user token: schedule:view); the
writes need visits:edit (user token: jobs:edit or schedule:edit — either, as in the app).
The client signature is not on the API; staff location and geofence events have their own section,
Location (GPS). The people to assign come from GET /team.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/visits |
visits:view |
| GET | /api/v1/visits/:id |
visits:view |
| POST | /api/v1/visits/:id/start |
visits:edit |
| POST | /api/v1/visits/:id/complete |
visits:edit |
| POST | /api/v1/visits/:id/photos |
visits:edit |
| POST | /api/v1/visits/:id/reopen |
visits:edit |
| PATCH | /api/v1/visits/:id |
visits:edit |
| POST | /api/v1/visits/:id/notes |
visits:edit |
| DELETE | /api/v1/visits/:id/notes/:noteId |
visits:edit (+ jobs:edit for someone else's note) |
| PUT | /api/v1/visits/:id/line-items |
visits:edit |
| POST | /api/v1/visits |
jobs:edit |
| DELETE | /api/v1/visits/:id |
jobs:edit |
Adding and removing a visit (Oct 2026) changes what the job IS, so it takes jobs:edit — the
role's jobs.edit on a user token; schedule.edit alone moves visits (PATCH) but cannot add or
delete one (403), exactly as in the app.
POST /api/v1/visits—{ jobId, scheduledStart, scheduledEnd }(full timestamps with an offset, UTC wall time like every visit window) or{ jobId, scheduleLater: true }(anUNSCHEDULEDvisit with no window). Optional:title,instructions,trackMileage, and the crew —assignedUserIds([]= nobody),leadUserId,crewId; with none of the three the visit takes the job's roster and crew label. ADONE/CANCELEDjob is409 visit_locked; an unknown or foreign job404; a foreign crew member or a bad window422. Double-bookings and after-hours windows only warn (meta.warnings).201with the visit (as in the list).DELETE /api/v1/visits/:id— removes the visit, its calendar events and files, and recomputes the job's window. Not gated on the job's status (removing a mistaken visit is a correction).{ "deleted": true }; foreign / unknown ids are404; a user token off the visit's crew withoutallVisits.editis403 not_assigned, like the web Delete.
List query
| Param | Type | Notes |
|---|---|---|
date |
YYYY-MM-DD or today |
One day. Either date or from+to is required — 422 validation_failed without them, with a malformed / impossible date, or with both. today is resolved in the company's timezone, so a phone that does not know it can still ask for the right day. |
from, to |
YYYY-MM-DD |
A range of days, both ends inclusive, always together (half a range is a 422), to not before from, at most 62 days. The team schedule. |
mine |
1 |
User tokens only: narrow to the visits this user is crew on (lead or helper). Ignored on a company key — a key has no user to be "mine" — never an error, so one request works for both principals. |
assignee |
user id | Narrow to the visits this person is crew on (any principal). An id that is not a member of your company matches nothing. mine together with assignee is a 422. |
customerId |
client id | Narrow to one client's visits (Oct 2026, the phone's client screen). Alone it lifts the date requirement: every visit of the client, newest first (future ones on top, then history; dateless visits last). With date or from+to it narrows that range. Another company's client id answers an empty list. |
cursor, limit |
as in Pagination | Ordered by the window's start, then id. An unknown cursor answers an empty page. |
Crew limit for user tokens. A user token whose role does not have "see everyone's visits"
(allVisits.view — the seeded Technician) only ever gets visits it is crew on, whatever the query:
the limit is ANDed with every filter above, so assignee=<colleague> answers the visits the two
share and customerId= answers only its own visits of that client — never a 403. GET /visits/:id
of a visit off its crew answers 403 not_assigned (an unknown or another company's id stays
404). A company key and a role with "see everyone's visits" are not limited (Oct 2026, T-869).
Which visits are "on" a day. The same rule the web calendar's day view uses: the visit's window
intersects the day (starts in it, ends in it, or spans it) — so a 22:00–02:00 visit is listed on
both days, exactly as /schedule draws it. A visit with no date yet (status: UNSCHEDULED) is
on no day at all; it is reachable only by id.
Response (list rows and the detail share the shape; the detail adds notes[], photos[] and requestImages[]):
| Field | Notes |
|---|---|
id, status |
status is SCHEDULED / IN_PROGRESS / COMPLETED / SKIPPED / UNSCHEDULED. |
title |
The label the calendar shows: the operator's own title, else "<Client> - <Job title>". |
date, startTime, endDate, endTime, anytime |
The window as the company wall clock reads it — YYYY-MM-DD and HH:mm (24h), the same digits the web calendar prints. anytime: true (a visit with a day but no time) carries date/endDate and null times. All null for an UNSCHEDULED visit. |
scheduledStart, scheduledEnd |
The same window as one ISO value each. Not an instant: it is the wall clock encoded as if it were UTC (2026-03-10T09:00:00.000Z means 09:00 in the company's timezone, whatever that is). Do not convert it; read the date/startTime fields instead. |
actualStart, actualEnd |
The real clock-in/out — TRUE instants (ISO UTC), null until they happen. |
timeLog |
The visit's worked time as intervals (T-981): { totalMinutes, intervals[] }, each interval { start, end, minutes, planned, people[] } (start/end ISO instants, planned: true = derived from the planned duration, people[] = { userId, name }). Every trip (start → complete, or a complete with a typed window) adds one; a reopen deletes none. totalMinutes is the sum of the intervals — show it, never actualEnd − actualStart, which describes only the current trip and is cleared by a reopen. |
arrivedAt |
ISO instant of the first geofence enter posted while the visit was still SCHEDULED (see Location (GPS)); stamped once, never moved, null otherwise. Independent of actualStart — Start is still the technician's act. |
instructions |
This visit's own crew brief (not the note thread). |
trackMileage, crewId |
Per-visit mileage flag; the dispatch crew label (informational — the people are crew[]). |
job |
{ id, jobNumber, title, status, instructions, notes }. |
customer |
{ id, name, phone, email } or null — primary phone/email, or the first on file. |
property |
{ id, addressLine1, addressLine2, city, state, zipCode, country, latitude, longitude } or null. |
crew[] |
{ userId, firstName, lastName, role, startConfirmedAt }, lead first (role is LEAD or HELPER). startConfirmedAt is the ISO instant THIS member started — or confirmed a dispatcher's start of — the visit from their own phone (a start with their user token), null until then; it is where that member's GPS shift window opens. |
lineItems[] |
{ id, productServiceId, name, qty, unit, unitPrice, total, isTextOnly } — the job's lines in order. unitPrice / total are null for a user token whose role lacks pricing.view (a key always sees them); isTextOnly marks a heading row (no money). |
noteCount |
Number of notes on the thread. |
createdAt |
ISO instant. |
notes[] (detail) |
{ id, authorId, authorName, body, createdAt, attachments[] }, newest first, at most 200; attachments[] = { id, fileName, mimeType, fileSize, url }, url signed (below). |
photos[] (detail) |
{ id, kind, pairId, url, thumbnailUrl, fileName, takenByName, takenAt, areaLabel, lineItemId } in capture order, at most 200; kind is BEFORE / AFTER, pairId links the two halves; url and thumbnailUrl are signed (below). Empty when the company's plan does not include Visit Photos (the rows are kept, not shown). |
requestImages[] (detail) |
The files the client attached to the request this visit's job came from — a request converted straight to the job, or one whose quote became the job: { id, requestId, fileName, mimeType, url, thumbnailUrl }, oldest first, at most 50; [] when the job has no request. url is signed (below); thumbnailUrl is signed too for an image and null for a document (a PDF has no preview). |
Every file URL on the visit card is signed — photos, note attachments (since T-773) and request images. Visit files live under
/uploads/visits/<visitId>/…, request files under/uploads/requests/…, both the session tier: the file is served to a signed-in web session of the owning company, and a bare path fetched with only anAuthorization: Bearerheader gets404. Each URL therefore carries a signature —/uploads/visits/<visitId>/<file>.webp?exp=<unix seconds>&sig=<base64url>— that the server accepts in place of a session. Prefix your instance origin and fetch it as is, no header needed. A signature opens exactly that path (the thumbnail has its own), expires one hour after the response that carried it (re-read the visit for fresh ones — do not cache them past that), and is not revocable before then; treat it like the file itself. Only a file inside the visit's own directory (orrequests/) is signed; a legacy row pointing anywhere else comes back as the bare path, which a Bearer-only client cannot fetch. None of this is world-readable — a visit photo is evidence at a customer's address, never a shareable-by-link object.
Start and complete — POST /visits/:id/start, POST /visits/:id/complete, optional body
{ "occurredAt": "<ISO 8601 with offset>" } (complete also takes workedStart/workedEnd, below). Both run the same lifecycle as the app's Start /
Finish buttons and answer 200 with the visit's current row (the list shape above, actualStart /
actualEnd / status / job.status updated):
occurredAt— when the button was pressed (T-845). A queued request may arrive long after the press; send the press instant and it becomesactualStart(start) oractualEnd(complete), and the crew's TimeEntries run between the two. It is honoured only inside a trust window — not in the future of the server clock, not older than 24 hours, and forcompletenot before the visit's ownactualStart; outside it, or when absent / not an ISO datetime, the server clock is used. Never a4xx: an untrusted stamp is ignored, not refused.Idempotent, by design for an offline queue. A replay writes nothing to the visit and still answers
200with the current state:starton anIN_PROGRESSorCOMPLETEDvisit,completeon aCOMPLETEDone. In particular astartthat reaches the server AFTERcomplete(a queue draining out of order) does not reopen the visit — the hours it logged stay logged. Retry on any network failure without looking at what you already sent.The one thing a
startreplay does write, on the crew row, not the visit: with a user token, astartby a member of the crew stamps that member's owncrew[].startConfirmedAt— on the first call and on a replay against anIN_PROGRESSvisit, because that replay IS the phone confirming a dispatcher's remote Start. It opens the member's GPS shift window (below). Never on aCOMPLETEDvisit, never for a company key, never for a caller who is not on the crew.Crew-limited user tokens (T-819): a user token whose role lacks
allVisits.editmay runstart,complete,reopen,PATCHandline-itemsonly on visits whose crew it is on (lead or helper); any other visit of the company answers403 not_assignedand nothing is written. An unknown or foreign id still answers404.start:SCHEDULED→IN_PROGRESS,actualStartstamped once (never moved by a second call); the job follows toIN_PROGRESS. Refused with409 visit_lockedwhen the job isDONE/CANCELED("Completed or canceled jobs can't get new visits."), when the visit has no date yet (UNSCHEDULED, "Schedule this visit before starting it.") or isSKIPPED("Reopen the visit before starting it."). The messages are the app's own — the technician and the dispatcher read the same sentence.complete: →COMPLETED,actualEndstamped,actualStartbackfilled from the planned window when the visit was never started, one TimeEntry per crew member for the actual window (a zero-minute visit logs none), and the job advances toREQUIRES_INVOICINGonce no visit remains open. Deliberately not gated on the job's status: a visit already on site when the job was closed must stay clockable out. Refused with409 visit_lockedonly forUNSCHEDULEDandSKIPPED.Each trip is its own interval (T-981). A
completewrites the interval of the trip being finished and never touches an earlier one. With noactualStart(never started): the planned window is backfilled only when the visit has no time logged yet; on a visit that already has time (a reopened one) it closes the visit and logs nothing — unless the body carries{ "workedStart": "<ISO>", "workedEnd": "<ISO>" }("Add time worked"), which becomes the new interval for every crew member. Both or neither; the end must be after the start and the window at most 12 hours — otherwise422 validation_failed(details.workedEnd) and nothing is written. A running clock-in wins over a typed window. Two racingcompletecalls log the interval once.
Photos — POST /visits/:id/photos, multipart/form-data: one or more files fields (≤10 per
call, ≤10 MB each, images only — the same pipeline as the app: re-encoded to webp at the visit
ceiling, EXIF stripped, a thumbnail beside each) and kind = before | after (either case).
Optional, for the app's zone wizard: pairId (links a BEFORE and its AFTER; the second half
inherits the first's zone), areaLabel, lineItemId (must be a line item of this job, else the
file is skipped with that reason). Response 201:
{ "uploaded": [<photo shape above, with signed url + thumbnailUrl>], "skipped": [{fileName, error}] }
— per-file resilient; every file refused → 422 validation_failed. The photographer is the user
behind the token (takenByName), or the key's label for a company key. Needs the company's plan to
include Visit Photos — otherwise 403 module_disabled, nothing stored (the read side answers
photos: [] for the same company).
Reopen — POST /visits/:id/reopen, no body. Takes back a Finish or a Skip exactly like the
app's Reopen: the visit lands on SCHEDULED with actualStart / actualEnd cleared and every
crew member's startConfirmedAt cleared (the next start opens a new interval and a new GPS shift
window). No TimeEntry is deleted (T-981) — the hours already logged stay in timeLog, paid or
not; a wrongly logged one is removed in the job's Time entries. The job's status follows (a
"Requires Invoicing" job whose visit is open again goes back). 200 with the visit row. Not idempotent,
unlike start/complete: a visit that is not COMPLETED / SKIPPED answers 409 visit_not_reopenable and nothing is written; a DONE / CANCELED job answers 409 visit_locked
("This job is completed or canceled — its visits can't be changed. Reopen the job first.").
Edit — PATCH /visits/:id, JSON, every field optional (at least one):
| Field | Type | Notes |
|---|---|---|
title |
string | "" = back to the derived calendar title. |
scheduledStart, scheduledEnd |
ISO with an explicit offset | The wall clock encoded as UTC, like the response (2026-10-05T10:00:00.000Z = 10:00 company time). A naive string is a 422. End before start is a 422; start = end only as the all-day marker. |
instructions |
string | The visit's crew brief. |
trackMileage |
boolean | |
assignedUserIds |
string[] | The WHOLE crew (absent = leave it; [] = nobody). Each must be an active member of your company, else 422. People ADDED get the "assigned" push. |
leadUserId |
string | null | With assignedUserIds: who leads (else the first one). |
crewId |
string | null | The crew label; null = no crew. |
status, actualStart, actualEnd are not accepted (422) — use start / complete / reopen. The
same edit as the app's Edit Visit dialog: the job's window follows a one-off job's only visit, an
empty job roster is seeded from the crew, connected calendars re-sync. 200 with the visit row;
double-bookings, out-of-hours windows and an empty crew warn, never block: one sentence each in
meta.warnings (a double-booking names the person and the other visit). 409 visit_locked for a
DONE / CANCELED job ("This job is completed or canceled — its visits can't be changed. Reopen the
job first.") or a window on a visit
with no date yet ("Drag this visit onto the calendar to schedule it." — dating it is the web
calendar's job); 422 validation_failed for the payload, a foreign crew or member.
Notes — POST /visits/:id/notes, multipart/form-data: body (text, ≤5000 characters; may be
empty when files are attached) and up to 10 files fields (≤10 MB each; the app's attachment
types). The note's author is the user behind the token, or the key's label for a company key.
Per-file resilient: a refused file is skipped and named in meta.warnings while the note is kept; a
files-only note whose every file was refused is not created (422). 201 with the note
({ id, authorId, authorName, body, createdAt, attachments[] }, the shape of notes[] above, attachment urls already signed).
Unlike the web app, a DONE / CANCELED job refuses a note with 409 visit_locked. Neither text
nor a file → 422.
Delete a note — DELETE /visits/:id/notes/:noteId (Oct 2026, T-930), the web's trash button: the
author deletes their own note; deleting anyone else's needs jobs.edit (a key: the jobs:edit scope)
— otherwise 403 insufficient_scope "You can only delete your own notes." and the note stays. A note
with no author (written by a company key, or by someone since removed) is nobody's own. A note of
another visit or another company is 404. A closed job does not lock it (as on the web). 200 with
{ id, deleted: true }; the note's attachments go with it.
Line items — PUT /visits/:id/line-items, JSON { "lineItems": [ … ] } — REPLACES the job's
lines whole ([] clears them), as the Edit Visit dialog's line editor does: only the rows change;
the job's price list, city, tax, discount and fees stay as stored and the totals are recomputed from
them. Each line: name (required), qty (≥0; >0 unless isTextOnly), unitPrice, and optional
productServiceId (must be in your catalog, else 422), description, unit, unitCost,
isTaxable (default true), isTextOnly (a heading: stored with no money), total (derived
from qty × price when absent or off by more than rounding), lineItemId (the id of the stored line
the row was loaded from — send it back on every existing row). unitCost is read only from a user
token whose role sees costs (costs.view + pricing.view) or a company key; for any other token the
posted cost is ignored and the server derives it: a row whose lineItemId names a line of THIS job
with the same productServiceId keeps that line's stored cost, any other catalog row takes the
catalog's current cost, a free-text row 0 — so a client that omits lineItemId resets a hand-set
cost to the catalog's (T-821). On a job whose items are split across visits (T-978) the visit row's
lineItems — and so the set this PUT replaces — is only THAT visit's own items; a row naming a line of
the job that belongs to another visit or to nobody answers 422 validation_failed (nothing written),
since recreating it here would duplicate it. The visit DTO carries itemsSplit (true when the job's items are split); send it back as itemsSplit in this body and a mismatch with the job's current state (it was split or merged since your GET) answers 422 validation_failed, nothing written — without it a list built from an unsplit view replaces the WHOLE job's items. Also send seenLineItemIds: the id of every lineItems row of the GET you edited (rows you removed included). When the visit's current set of items differs (an item was moved onto or off this visit since) it answers 422 validation_failed, nothing written; without it only the foreign-row refusal protects you. 200 with the visit row. A user token
whose role lacks pricing.view is refused whole — 403 prices_hidden, nothing written. A
CANCELED job → 409 visit_locked.
Errors: 404 not_found for a visit of another company (never 403); 403 insufficient_scope
naming visits:view / visits:edit (key) or schedule:view / jobs:edit or schedule:edit (user
token); 409 visit_locked with the sentence above (visit_not_reopenable for a reopen);
422 validation_failed for a bad query, body, kind or no file.
Location (GPS)
On-shift staff location (Sep 2026, T-509). The phone streams position samples while the technician
is on shift and reports the crossings of each visit's address radius; the dispatcher reads the
newest pin per member. Every write is a user token endpoint — a location belongs to a person's
phone, and a company key answers 403 user_token_required before any lookup. All four are gated by
the company switch (/settings/company → Field staff location, off by default): while it is off
the two writes answer 403 tracking_off and nothing is stored. There is no plan module.
| Method | Path | Principal / scope |
|---|---|---|
| POST | /api/v1/gps/batches |
user token — any active member (their own points; no role permission) |
| POST | /api/v1/gps/geofence-events |
user token — jobs:edit or schedule:edit (the grants Start takes) |
| GET | /api/v1/gps/latest |
visits:view (key) / schedule:view (user token; colleagues only with schedule.delete) |
| GET | /api/v1/gps/settings |
visits:view (key) / any active member (user token) |
The shift-window rule, in the phone's terms. A point is stored only when its recordedAt falls
inside one of the caller's OWN shift windows. A window opens at the instant the technician taps
Start on their phone — POST /visits/:id/start with their user token, which stamps
crew[].startConfirmedAt for them alone (the helper's phone confirms for the helper). It stays open
while that visit is IN_PROGRESS and closes 10 minutes after the visit is completed
(actualEnd + 10 min, the drive off site). A dispatcher's Start from the web opens nothing — the
visit is IN_PROGRESS but the technician's startConfirmedAt is still null — so the app must
replay start when it sees a visit of the signed-in member go IN_PROGRESS without their own
stamp; that replay is the confirmation (see Visits). SKIPPED, UNSCHEDULED and
SCHEDULED visits open no window. Read the window's edges from GET /visits/:id:
crew[].startConfirmedAt, status, actualEnd.
POST /api/v1/gps/batches — drain the location queue. Body (strict), 1…500 points
(501 → 422 validation_failed):
{ "points": [
{ "lat": 51.0447, "lng": -114.0719, "accuracy": 8, "speed": 0, "heading": 90,
"batteryLevel": 0.8, "activity": "still", "recordedAt": "2026-03-10T16:02:11.000Z" }
] }
lat−90…90,lng−180…180,accuracymetres ≥ 0,speedm/s ≥ 0,heading0…360,batteryLevel0…1,activitythe OS motion class (≤ 32 chars) — all butlat/lng/recordedAtoptional.recordedAtis the device's timestamp of the fix (ISO), not the send time.- Response
200 { data: { accepted, duplicates, rejected } }.duplicates= points already stored (dedup onrecordedAtper user — replay a whole batch freely);rejected= points outside every window, or stamped more than 5 minutes in the future (a broken clock). A partial batch is still200: drain the queue on any 2xx. 403 outside_shift— no point of the batch fell inside a window; nothing was written. The app should stop streaming and wait for the next Start.
POST /api/v1/gps/geofence-events — the phone entered or left a visit's address radius
(geofenceRadiusM from /gps/settings). Body (strict):
{ "visitId": "clx…", "kind": "enter", "at": "2026-03-10T15:58:40.000Z" }
kind—enter|exit;at— the device's instant of the crossing.- Not gated by the shift window: the first visit's
enterprecedes the first Start, which is the point of auto-arrival. Gated by the switch and by the caller being on the visit's crew. - An
enterwhile the visit is stillSCHEDULEDstampsarrivedAtonce — answersarrivalMarked: true; every other crossing (a secondenter, anyexit, anenteron anIN_PROGRESSvisit) is recorded and answersarrivalMarked: false. The status is re-checked on the server, never trusted from the phone. - Idempotent on
(visitId, kind, at)per caller: a replay is200with nothing new stored. - Response
200 { data: { visitId, kind, at, arrivedAt, arrivalMarked } }. Errors:404 not_found(unknown id or another company's visit — never403),403 not_assigned(a member who is not on this visit's crew),403 tracking_off,403 user_token_required.
GET /api/v1/gps/latest?userId= — the newest stored point per member of the caller's company,
with onShift (whether that member is inside a confirmed window right now — a pin from yesterday
must not read as live). ?userId= narrows to one member; an unknown or foreign id answers []. Whose points (T-600,
Law 25): a company key (visits:view) and a user token whose role holds schedule.delete (runs everyone's schedule — Admin, Manager,
Front Desk; the Technician does not hold it) see every member; any other user token (schedule:view only) sees just its OWN newest point, and
?userId=<colleague> answers [] exactly like an unknown id.
Response 200 { data: [ { userId, firstName, lastName, lat, lng, accuracy, speed, heading, batteryLevel, activity, recordedAt, onShift } ] }.
GET /api/v1/gps/settings — what the phone needs before it starts the location SDK:
200 { data: { trackingMode, retentionDays, shiftTailMinutes, geofenceRadiusM } }. trackingMode is
off | shift_only; show the disclosure and arm the SDK only when it is not off. Re-read it on
each app start — the owner can flip the switch at any time and the writes refuse immediately.
Retention. Points are deleted 30 days after recordedAt by a daily job (retentionDays);
geofence events and arrivedAt are visit facts and stay with the visit. A member can download their
own points as CSV from the web (/api/gps/export?userId=), as can anyone with settings.edit.
Inbox
inbox scope — the web inbox (/conversations) for the phone (Sep 2026): the thread list, a
thread's messages, a reply (with photos), starting a thread with a client's number (Oct 2026,
T-910), mark read, the thread owner and "Mark handled", the
canned-response scripts (Oct 2026, T-834). A user token needs conversations:view for the reads,
conversations:create to send and conversations:edit to assign or mark handled — the same grants
the web inbox checks. Every endpoint also needs the Inbox plan module (403 module_disabled otherwise, for both principals — the module
is about the surface, not about keys).
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/inbox/threads |
inbox:view |
| POST | /api/v1/inbox/threads |
inbox:create |
| GET | /api/v1/inbox/threads/:id/messages |
inbox:view |
| POST | /api/v1/inbox/threads/:id/messages |
inbox:create |
| POST | /api/v1/inbox/threads/:id/read |
inbox:view |
| POST | /api/v1/inbox/threads/:id/owner |
inbox:edit |
| POST | /api/v1/inbox/threads/:id/handled |
inbox:edit |
| GET | /api/v1/inbox/attachments/:id |
inbox:view |
| GET | /api/v1/inbox/scripts |
inbox:view |
A thread is one conversation with one address on one connected line (an SMS number, a Messenger page, an email account) — what the web inbox lists on the left. Threads are created by inbound traffic, by sends from the web and by Start a thread below (SMS to one of a client's phone numbers); every other API send replies on an existing thread.
Thread list — ?cursor, ?limit as in Pagination; ?customerId= narrows to one
client's threads (Oct 2026, the phone's client screen — another company's client id answers an
empty list). Ordered by last activity
(lastMessageAt), newest first, then id. A thread that receives a message while you page moves to
the top — it may appear on a later page again or be skipped once; refresh from page one to see the
current order. An unknown cursor answers an empty page.
?filter= — a comma list of mine, unassigned, needs_action: the web inbox's three chips,
applied by the same predicate, so they combine (AND) the same way (mine,needs_action = my
unanswered threads). mine and unassigned cannot both hold — mine wins, as on the web. mine
needs a user token (a company key → 422); an unknown token → 422 validation_failed.
The list envelope carries meta.smsConfigured — false when the company's Telnyx integration is
off, has no key or no active line, i.e. every SMS reply would fail. Show the "SMS is not set up"
banner from it before the user types.
| Field | Notes |
|---|---|
id, channel |
channel is SMS / EMAIL / MESSENGER / INSTAGRAM / … |
contactNumber |
The other party's address on that channel: E.164 for SMS, an email address, a Meta user id. |
customer |
{ id, name } or null when the address matched no client. name falls back to the address for a client with no name on file. |
lineLabel |
The connected line's label ("Main line", the mailbox name); the address when unlabelled. |
lastMessage |
{ id, body, direction, createdAt } — the newest message whichever way it went; null on an empty thread. |
lastCall |
{ id, direction, missed, label, createdAt } — the thread's newest call event (a bridge child leg is not a second call), or null when the thread has none. label is the web inbox's preview wording: "Incoming call · 0:18" (talk time, m:ss), "Outgoing call", "Missed call"; missed is true for an INBOUND call nobody picked up (the timeline item's meaning). A call has no message row, so the row's preview is the NEWER of lastMessage and lastCall by createdAt — a tie goes to the message, as on the web. Read with the page, never one query per thread. |
unreadCount |
Inbound messages not yet read on any surface. |
lastMessageAt, lastInboundAt, createdAt |
ISO instants; lastInboundAt is null until the contact has written. |
pinned |
The operator pinned the thread in the web inbox. The list is not pinned-first (the order is activity); hoist pinned rows on the client if you want the web's shape. |
owner, unassigned, assigneeId |
Who answers the thread — { id, name } or null; unassigned is owner === null. |
needsAction |
An inbound message or missed call nobody has answered yet. A staff reply clears it, and so does POST …/handled; READING does not (unread and handled are separate). |
Messages — GET …/threads/:id/messages?cursor&limit: the thread's timeline, newest
first, the cursor paging into older history — the phone opens a thread at the bottom and scrolls
up. Since Oct 2026 (T-768) the timeline holds messages and calls, each item discriminated by
kind: "message" | "call" — the calls are exactly the ones the web thread shows inline (an inbound
call threaded into it, a call-back placed from it, and for older rows this client's calls on the
thread's number; SMS threads only, bridge child legs never). The cursor is opaque (it encodes
the position across both kinds, not a message id) — pass back pagination.nextCursor verbatim; one
that does not decode answers an empty page. Timestamps never change, so a cursor stays valid while
new items arrive (they land on a fresh page one, never inside a page already served). A thread of
another company is 404 not_found.
kind: "message":
| Field | Notes |
|---|---|
kind |
"message" (also on the POST echo). |
id, threadId, channel, direction |
direction is INBOUND / OUTBOUND. |
body, subject |
Plain text; subject is set on email messages only. The web's sanitized inbound HTML is not exposed. |
status, errorMessage |
QUEUED / SENT / DELIVERED / FAILED / … per the provider; errorMessage is set on a FAILED send. |
sentAt, deliveredAt, createdAt |
ISO instants, the first two null until the provider reports. |
mediaUrls[] |
Site-absolute /uploads/… urls of images sent with the message (MMS, channel attachment). Inbound ones are signed (see below). |
attachments[] |
Discriminated by kind. "file" — email files: { kind, url, filename, contentType, sizeBytes }; inbound urls are signed. "audio" (Oct 2026, T-1060) — an inbound voice MMS: { kind, id, url, filename, contentType, sizeBytes, durationSeconds }, url = /api/v1/inbox/attachments/<id> (fetch it with your Bearer token, below), contentType usually audio/mp4 (re-encoded from the carrier's AMR; the original type when re-encoding was impossible), durationSeconds may be null. A voice-only message has an empty body. An inbound attachment of a type the app cannot show arrives as a line [Attachment not supported: <name> · <type>] in body. |
pinned |
The operator pinned this message in the web thread. |
kind: "call":
| Field | Notes |
|---|---|
kind, id, threadId, direction |
"call"; id is the CallRecord id; direction INBOUND / OUTBOUND. |
status |
The provider's technical outcome: RINGING / IN_PROGRESS / COMPLETED / NO_ANSWER / BUSY / FAILED / … |
outcome, outcomeReason |
The operator's wording (the web badge): an unanswered inbound call is "Missed" with the reason ("No answer", "Busy", "Failed") in outcomeReason; a call nobody could be rung for (nobody on shift, no device, the line not voice-enabled, after hours) reads "Nobody on shift" instead of "Failed"; otherwise outcomeReason is null. |
missed |
true for an inbound call nobody picked up — the inbox's "missed call". An outbound call the customer did not answer is false (its outcome says why). |
durationSeconds |
Talk time, null until the call ends. |
hasRecording |
A recording exists — the conversation's recording or a voicemail. Play it from GET /api/v1/calls/:id/recording (see Calls). |
voicemail |
{ durationSeconds } when the caller left a voicemail on this (missed inbound) call, else null. durationSeconds may be null when the carrier reported no length. The voicemail audio is the call's recording. |
startedAt, endedAt, createdAt |
ISO instants; the timeline orders by createdAt. |
Media URLs are two tiers. Outbound media (
/uploads/messages-out/…) is public — providers fetch it by URL — and is returned as a bare path. Inbound media and email attachments (/uploads/messages/…) are gated: the bare path opens only for a signed-in web session of the owning company, so the API returns them signed (…?exp=<unix>&sig=<hmac>, valid 1 hour, like visit photos and receipts) — prefix the API origin and fetch without any header. An expired url: re-read the page.
Voice attachments — GET /api/v1/inbox/attachments/:id → the audio bytes (Content-Type from
the attachment, Cache-Control: private, no-store). Unlike inbound images these files live outside
/uploads and are never signed: send the same Authorization: Bearer … header as for any other
call. Range: bytes=… is honoured (206 + Content-Range; unsatisfiable → 416), so a player can
seek. Another company's id, or an unknown one, is 404 not_found.
Send — POST …/threads/:id/messages with { "text": "…", "subject"?: "…" } → 201 with the
message (status: SENT once the provider accepted it). The reply goes on the thread's channel
from the thread's own line to the thread's address — the body carries no recipient and no channel,
exactly like replying in the web composer. subject applies to email threads only (default:
Re: <the last inbound subject>); it is ignored elsewhere. Photos (MMS, T-834): send
multipart/form-data with the same text (may be empty when a photo rides along) and up to 5
media image files, each ≤ 10 MB — the server stores them in the public outbound tier and the
provider fetches them from there. A non-image or an oversized file → 422 validation_failed
naming media, and nothing is sent. A JSON attachments / url key is still refused (422): the
server never sends a file the caller merely names. With a user token the email sign-off
block is the sender's (name + signature); a company key sends unsigned.
The send is the same function the web composer calls, so every refusal it makes reaches you with the web's own sentence:
| Status | code |
When |
|---|---|---|
409 |
recipient_opted_out |
The number replied STOP — "This number has opted out of messages (STOP)." No message is stored. (A client's SMS/email opt-in flags do not block a manual reply, on the web or here — they gate automated sends only.) |
409 |
reply_window_closed |
Messenger / Instagram: more than 24 h since the contact's last message, or they never wrote. The web disables its composer with the same sentence. |
403 |
module_disabled |
An email thread on a plan with neither the Email channel nor Mailboxes module (the Inbox module alone does not unlock outbound email). |
422 |
send_failed |
Anything else the send refuses — no active line / connected account, or the provider rejected the message (then a FAILED row with errorMessage is on the thread). |
404 |
not_found |
Not this company's thread. |
Start a thread — POST /api/v1/inbox/threads with
{ "customerId": "…", "toNumber": "+14165550199", "text": "…" } → 201
{ data: { thread, message, threadCreated } } (Oct 2026, T-910). The API twin of the web
New conversation dialog: an SMS from the company's line to one of the client's phone numbers,
sent by the same function the dialog's draft thread uses. toNumber may be in any format
((416) 555-0199) — it is normalized and must be one of that client's phones. There is one
thread per number in a company: if the number already has a thread (on any line), the message
lands on it (re-homed onto the current line) and threadCreated is false; otherwise a new
thread linked to the client is created and threadCreated is true. thread has the shape of a
thread list row, message the shape of a timeline message. Unknown body keys (a
fromNumberId, a media url) are 422 — the line is the company's, never the caller's.
Photos: multipart/form-data with the same three text fields (text may be empty when a
photo rides along) and up to 5 media image files, each ≤ 10 MB, exactly as for a reply.
A refusal leaves no thread and no message; the client and the number are checked before anything is written, and photos are stored only once a line exists to send them. (A STOP refusal of a send with photos keeps the uploaded files unreferenced, exactly as for a reply.)
| Status | code |
When |
|---|---|---|
403 |
insufficient_scope |
The key lacks inbox:create, or the user's role lacks conversations.create. |
403 |
module_disabled |
The plan has no Inbox module. |
404 |
not_found |
customerId is not a client of this company (another company's id included) — "Client not found". |
409 |
recipient_opted_out |
The number replied STOP — the web's sentence. No thread or message is written. |
422 |
validation_failed |
A missing / empty field, an unknown key, a non-image or oversized media file, or toNumber is not one of the client's numbers (details.toNumber). |
422 |
send_failed |
Anything else the send refuses, in the web's words — e.g. no Telnyx line ("Telnyx is not configured, disabled, or has no active number."), or the provider rejected the message (then a FAILED row is on the thread). |
Mark read — POST …/threads/:id/read → 200 { id, unreadCount: 0 }. Idempotent; clears the
counter on every surface (the web badge included). inbox:view, as on the web — reading what you
may see is not an edit. A read that changed the counter also notifies every other open inbox of the
company: the realtime service sends { kind: "message", id: <threadId> } (the same refresh hint as
a new message), so a colleague's badge drops without a reload.
Owner — POST …/threads/:id/owner { "userId"?: string | null } → 200 with the thread.
No userId = Take it (the caller; a company key must name someone → 422), a member id =
hand it over, null = release. The target must be an active member of the company (422
otherwise). conversations:edit, as the web's owner bar.
Mark handled — POST …/threads/:id/handled { "handled"?: boolean } → 200 with the thread;
an empty body is handled: true (clears needsAction), false flags it again.
conversations:edit.
Scripts — GET /api/v1/inbox/scripts → { data: [{ id, title, shortcut, body, isShared, isMine, ownerName }] }: the canned responses the composer's "/" inserts — the caller's own scripts
(private ones included) plus every script a colleague shared, alphabetical. A company key gets the
shared ones only.
Not on the API (deliberately): starting a thread on any channel but SMS or with an address that is not a client's phone, pin / unpin, mark unread, editing scripts, calls in the thread timeline.
Payments
payments scope. A company key is read-only — the scope is capped to view, so POST answers
403 to every key. Only a user token (the mobile app) whose role holds payments.edit records a
payment by hand; online money still moves through Stripe only.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/payments |
payments:view |
| GET | /api/v1/payments/:id |
payments:view |
| POST | /api/v1/payments |
payments:edit (user token only) |
| POST | /api/v1/payments/:id/receipt |
payments:edit (user token only) |
| GET | /api/v1/payment-methods |
payments:view |
| GET | /api/v1/payment-settings |
payments:view |
| POST | /api/v1/gift-cards/lookup |
payments:edit (user token only) |
List filters: invoiceId. Order: newest paidAt first (the web register's order), then
id; a payment with no paidAt comes last. The cursor walks that order.
Response: id, invoiceId, amount, tipAmount, currency, method, status,
checkNumber, paidAt, notes, createdAt. checkNumber is the cheque number recorded with the
payment (the checkNumber of POST /payments), null when none was given (Oct 2026, T-914). (Stripe/external ids, processing fees, and card details are never exposed.)
The list rows add what the phone's payments screen prints (T-834): customerName (the
invoice's client, null for a payment with no invoice), invoiceNumber (integer or null) and
refundedAmount — the sum of this payment's refunds that did not fail, a number like amount
(0 when nothing was refunded; status: PARTIALLY_REFUNDED says that, this says how much).
amount is the invoice portion and tipAmount is gratuity ON TOP — the charge gross is
amount + tipAmount (see the tip convention in features/payments.md).
method is no longer a fixed enum: it returns the company's payment-method key — the built-in
keys (CARD, CASH, CHECK, E_TRANSFER, BANK_TRANSFER, OTHER, plus legacy values on older
rows) or a pm_… key for a method the company created itself. Treat it as an opaque string.
Record a payment — POST /api/v1/payments
Cash, an e-transfer or a cheque a technician collected on site, recorded against one invoice (T-565). It is the same write as the web Collect Payment dialog (one shared core), so everything below is the dialog's rule, not an API variant.
Body (JSON, unknown keys → 422):
| Field | Notes |
|---|---|
invoiceId |
Required. Another company's invoice → 404 "Invoice not found". |
amount |
Required, > 0. The bill portion — capped by the invoice's balance due (422 "Amount exceeds balance due (…)"). |
tipAmount |
Optional, ≥ 0, gratuity on top — not capped by the balance. |
method |
A key from GET /payment-methods (legacy built-in keys are accepted too). Unknown key → 422 "Unknown payment method". May be omitted when the wallet's mapping supplies it; omitted with nothing to supply it → 422. |
walletId |
Where the money was deposited. Three states: a wallet id; null = explicitly no wallet; absent = unanswered, which is refused (422 "Choose where this payment was deposited") when the Wallets module is on and the company has an active wallet — exactly like the dialog. Another company's wallet, an inactive one or the Stripe processor wallet → 422 "Wallet not found or inactive". Depositing needs wallets.edit (403 otherwise). |
paidAt |
Optional YYYY-MM-DD, read in the company's timezone; omitted = now. A future day → 422. |
checkNumber, notes |
Optional strings (50 / 2000 chars). |
giftCardCode |
With method: "GIFT_CARD" only (ignored otherwise; T-872): the card being redeemed, any spelling a person types (case, spaces, dashes forgiven). Offered only where GET /payment-settings says giftCardsEnabled — otherwise 403 module_disabled. Another company's or an unknown code → 404 "Gift card not found" (no balance in the body). Not enough on the card → 422 "The gift card has only $X available". A tip can't be paid with a card (422), no wallet is involved, and the card's balance is debited in the same transaction as the payment. |
The wallet only supplies a default method (since Oct 2026, D-15: any method into any wallet). A
posted method is always recorded as posted, inside the wallet's paymentMethodKeys or not — a
Check into the Cash wallet is legal. Only when method is omitted does the wallet supply it:
its defaultPaymentMethodKey, or, when that is null, the first key of paymentMethodKeys. Mapped
CUSTOM keys the company has archived or switched off are never supplied (builtins always are); with
none left and no method posted → 422. (Before Oct 2026 a posted method outside the wallet's set
was replaced by the wallet's default.)
Response 201: the payment (same shape as GET /payments) plus
invoice: { id, status, amountPaid, balanceDue } — the invoice after the recompute, so a full
payment answers status: "PAID", balanceDue: 0. With a wallet, the gross (amount + tipAmount)
is booked into it as income. The client gets the usual email/SMS receipt. Every refusal writes
nothing, and every error.message is a sentence the phone can show verbatim.
Send a receipt again — POST /api/v1/payments/:id/receipt
The web Send receipt dialog (Oct 2026, T-930): the same receipt the client got when the payment was recorded — the company's Payment Receipt template, the amount paid (tip included), the paid invoice PDF attached when the payment closed the balance. Body (optional — an empty POST e-mails it):
| Field | Type | Default | Notes |
|---|---|---|---|
email |
boolean | true |
To the client's primary email. |
sms |
boolean | false |
To the client's primary phone; needs the SMS Notifications plan module. |
The client's opt-in flags do not block it — a receipt is transactional. A channel the client has no address for is skipped silently, as on the web.
| Status | Code | When |
|---|---|---|
| 200 | — | { id, email, sms } — the channels that were asked for. |
| 403 | insufficient_scope |
The role lacks payments.edit, or the caller is a company key. |
| 404 | not_found |
No such payment in your company. |
| 409 | conflict |
The payment is not linked to an invoice, or it is refunded / voided. Nothing is sent. |
| 422 | validation_failed |
Both flags false, sms: true without the SMS module ("Text messages are not part of your plan."), or an unknown key. Nothing is sent. |
Payment methods — GET /api/v1/payment-methods
The methods the dialog offers: the company's methods that are enabled and not archived, in the
configured order. data[]: key, label, icon (a Lucide icon name), builtin (one of the
curated six rather than a company-made pm_… method), isDefault (preselect it). No pagination.
Payment settings — GET /api/v1/payment-settings
What Collect payment renders besides the method list (T-872), read fresh each time the screen opens
(the session is not refreshed when the office changes a setting). data:
tipsEnabled— the company's "Allow tips" switch;false→ hide the tip row.tipPresets—[{ type: "PERCENT" | "FIXED", value }], exactly the web dialog's buttons (PERCENT of the amount, FIXED in the company currency).[]means "Custom amount only", not "use defaults".giftCardsEnabled— offer the Gift card tender (method: "GIFT_CARD"+giftCardCodeonPOST /payments): a salon company, or any company where a gift card exists — the web dialog's rule.
Gift card balance — POST /api/v1/gift-cards/lookup
"Check balance" before redeeming (T-872). Body { "code": "…" } — POST, not a query string, because
a code spends like cash and URLs end up in logs. Needs payments.edit (the right that redeems) and
giftCardsEnabled (403 module_disabled otherwise). data: code (canonical form), balance,
status (PENDING — not paid for yet, ACTIVE or VOID), expiresAt (ISO or null). Another
company's code and an unknown one are the same 404 — never a balance.
Wallets
wallets scope (T-507, the manager's balances screen and the "paid from" picker on the phone).
Read-only over the API: transfers, manual income and wallet settings stay on the web (a payment recorded with POST /payments books its deposit into the chosen wallet).
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/wallets |
wallets:view |
No pagination — a company has a handful of wallets and the phone renders them as one list. Needs the
Wallets plan module (403 module_disabled otherwise, both principals).
Who sees what. A user token gets the web's own three buckets, in picker order: the wallets
the person owns, the company's (owner: null), and colleagues' wallets shared with them through a
wallet permission. A colleague's private wallet is absent — not listed with a flag — EXCEPT
for a company admin, who also gets every employee's personal wallet, last, with its owner and
balance and selectable: false — the web's "Employee Wallets" section, same predicate (T-834):
seeing a colleague's wallet is not paying out of it. A company key gets every wallet of the
company (company-level trust; a key has no person to be "mine").
Response (data[]):
| Field | Notes |
|---|---|
id, name |
|
type |
{ name, color } or null — the operator's wallet type ("Cash", "Bank"). |
owner |
{ id, name } for a personal wallet, null for a company wallet. |
balance, currency |
The stored balance as a number beside the ISO currency code (cad) — format on the device; never a preformatted string. null on a company wallet (owner: null, the Stripe one included) for a user token whose role lacks payments:view and is not a company admin (T-704; the key was wallets:edit until T-821 — the Technician still picks the company card for an expense, he does not read its balance; the web hides the same balance since T-687). A company key always gets the number. |
isActive, isDefault |
|
selectable |
false = visible, never a choice. An employee's wallet in the admin's list (above), the Stripe processor wallet (processor: "stripe" — it mirrors what Stripe holds and is written only by the ledger) and any inactive wallet are listed so the balance is on screen, but must not be offered as a "paid from" / "received into" target; POST /expenses refuses them (404 / 422). Every web picker applies the same rule. |
processor |
"stripe" or null. |
paymentMethodKeys |
The wallet's payment-method mapping (T-565): the methods a payment received into it can STILL record — already filtered by the same rule the Collect Payment dialog and POST /payments apply (a builtin key always counts, even when its row is switched off; a custom key only while it is enabled and not archived); [] when unmapped or nothing usable is left. Use it as-is — do NOT intersect it with GET /payment-methods: exactly one key → hide the method select (the server records that key whatever is posted); several → constrain the select to them; none → offer the full GET /payment-methods list. A key that is absent from GET /payment-methods is a builtin whose row the company switched off — render it under its builtin name (CASH → Cash). |
defaultPaymentMethodKey |
What the dialog preselects: the wallet's stored default while it is usable, else the single usable key, else null ("ask every time"). |
createdAt |
ISO instant. |
Expenses
expenses scope (T-507, T-566): a technician books an expense on the spot, receipt photo included,
and the money moves exactly as the web form moves it; the phone reads its category picker and the
technician's recent expenses from the two GETs.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/expenses |
expenses:view |
| GET | /api/v1/expense-categories |
expenses:view |
| POST | /api/v1/expenses |
expenses:create |
All three need the Expenses plan module (403 module_disabled).
List expenses — GET /api/v1/expenses
Who sees what. A user token gets the caller's OWN expenses only — those that belong to them
(userId) and those they booked from a colleague's shared personal wallet (such an expense
belongs to the wallet's owner, see below, but the booker must still see it landed) — even when the
role could see the whole register on the web. A company key gets every expense of its company.
Newest expense day first (date, the wall day the money was spent — the web register's order,
T-834), then booking time (createdAt) within a day, cursor-paginated (?cursor, ?limit 1–100, default 25 —
see Pagination).
| Query | Notes |
|---|---|
walletId |
Only the expenses paid from that wallet (the wallet of an expense is its EXPENSE wallet transaction). An unknown or foreign id simply matches nothing. |
Each item is the same DTO as the 201 answer of the booking below; wallet is the wallet it was
paid from with its current balance, null for an expense booked on no wallet. receipts[].url
is signed afresh on every read.
wallet.balance is null when the caller could not read that wallet on GET /wallets — the
wallets module off, no wallets:view (role for a token, scope for a key), or, for a user token, a
wallet outside its owned / company / shared set, or a company wallet without payments:view /
company admin (the same rule as GET /wallets, T-704). The paying wallet is not always one the caller may
see: payroll books a pay statement as the employee's expense from any company wallet (the owner's
personal card included), and a revoked share leaves the booker's old expenses on the colleague's
wallet. wallet.id and wallet.name stay — which wallet paid is the expense's own fact.
Because the booker counts as "mine", whoever pays payroll also finds every pay statement they paid in their own list — the same rows they already see on the web's payroll pages.
Categories — GET /api/v1/expense-categories
The phone's category picker — exactly what the web form /expenses/new offers:
{ "data": { "required": true, "categories": [ { "id": "…", "name": "Fuel", "code": "FUEL" } ] } }
required is whether the Expense Categories module is on. On: categories are the company's
active accounting codes by name, and POST /expenses refuses a missing category (422); any
id from this list is accepted. Off: categories is [] — the booking ignores a category while the
module is off, so offering one would be a choice silently dropped. No pagination.
Book an expense — POST /api/v1/expenses
User tokens only. Expense.userId is not nullable — an expense belongs to a person — and a
company key is not one, so a dw_live_… key answers 403 user_token_required before any lookup.
The role needs expenses:create, as the web form does.
Body — multipart/form-data when a receipt rides along, application/json when none does; the
same fields either way, unknown fields 422:
| Field | Notes |
|---|---|
amount |
Required. A plain decimal string (or number), at most 2 places, greater than zero — "45.50". Refused, never rounded: 45.505, 0, -1, abc → 422 naming amount. The value is booked as a Decimal — the wallet reads 954.50 after a 45.50 on 1000.00, never 954.4999…. |
walletId |
The "paid from" wallet. Required while the Wallets module is on, ignored while it is off (the form hides the picker) — 422 naming walletId when missing. Must be one of the company's own, active, non-processor wallets: another company's id, the Stripe wallet and an unknown id are one 404 not_found ("Wallet not found"); an inactive one is 422 ("This wallet is inactive"). |
category |
The accounting code id. Required while the Expense Categories module is on, ignored while off — 422 naming category. Another company's code is 404. |
note |
Free text, ≤ 2000 chars → the expense's description. |
title |
≤ 200 chars, optional: the row's headline. Falls back to the note's first line, then the category name, then "Expense" — a technician typing "Gas for the van" is not asked for a headline. |
date |
YYYY-MM-DD, optional; default today in the company's timezone. Stored as the wall date (UTC midnight), as the web form stores it. |
jobId |
Optional; another company's job is 404. |
isReimbursable |
Optional boolean (true/"true"/"1"; anything else → 422). A reimbursable expense starts AWAITING_PAYMENT and needs no walletId (paid out of pocket — the web lists it as to reimburse and no wallet is debited). |
taxRateId |
Optional (T-921). Absent → the company's default rate (the one starred in Settings → Taxes — meta.defaultTaxRateId of Tax rates — else the onboarding default), null (or "" in multipart) → no tax. Must be one of your non-archived rates: another company's or an archived id is 422 with details.taxRateId = ["Tax rate not found"] and nothing is written. The tax is carved OUT of amount (the receipt's tax-included total) by the rate's components; amount stays the gross and is what the wallet is debited. |
taxAmounts |
Optional: the receipt's own cents per component, { "<componentId>": "5.02", … } (a JSON string in multipart) — "match the receipt" instead of the computed split. Must name exactly the rate's components, each ≥ 0 with at most 2 decimals, and add up to less than amount; otherwise 422 naming taxAmounts ("Tax cannot exceed the total" for the last rule). Without a rate → 422. |
vendor |
Optional, ≤ 200 chars: who issued the receipt. |
receipt |
Multipart only: one or more files (≤ 5 per call, ≤ 10 MB each — images or documents, a PDF invoice is fine). Uploaded per-file resiliently: a rejected file is skipped and logged, the expense still lands. |
What happens. The same core as the web's Add Expense: the wallet is
resolved by { id, companyId, processor: null }, the category by { id, companyId }, the Expense
row is numbered (EXP-N), the receipt goes into /uploads/receipts/ — the session tier, never
world-readable — the wallet balance is debited inside one transaction and a WalletTransaction
of type EXPENSE is written beside the row. If the category is the equipment-purchase code and no
unit is named, the equipment row is minted as on the web (equipmentId in the answer). The expense
belongs to the wallet's owner for a personal wallet, to the caller for a company wallet — the web
rule.
Response 201 (data):
| Field | Notes |
|---|---|
id, number, title, description, amount, date |
date is YYYY-MM-DD. |
categoryId, categoryName, jobId, equipmentId, userId |
|
walletId, wallet |
wallet = { id, name, currency, balance } after the debit — the new balance without a second call. Both null when booked on no wallet (module off). balance is null when the caller may not read that wallet (see the list above). |
isReimbursable, reimbursementStatus |
|
vendor |
null when not given. |
tax |
{ rateId, total, breakdown[] } (T-921) — the tax INSIDE amount. Money as 2-decimal strings: total "13.00"; each line { componentId, name, kind, rate, recoverable, amount } (kind GST_HST / QST / PST / OTHER, rate a fraction, amount "13.00"), frozen when the expense was saved — a rate edited later does not rewrite it. No rate → { rateId: null, total: "0.00", breakdown: [] } (also every expense from before T-921). Same shape in GET /expenses. |
receipts[] |
{ url } per stored receipt, signed exactly like visit photos (?exp=&sig=, one hour, that path only) — the phone holds no cookie and the tier is gated. Re-read is not on the API: keep the url while it is valid or re-upload. |
createdAt |
ISO instant. |
Errors: 403 user_token_required (a key); 403 insufficient_scope naming expenses:create;
403 module_disabled; 404 not_found for a wallet / category / job of another company (never
403); 422 validation_failed with details per field; 400 invalid_json.
Public links
public scope — read-only (T-507): the addresses a technician shows a client as a QR, with the
same numbers the web prints. Nothing about these pages is written over the API.
| Method | Path | Scope | Module |
|---|---|---|---|
| GET | /api/v1/review-links |
public:view |
reviews |
| GET | /api/v1/quick-links |
public:view |
quickLinks |
Both answer 403 module_disabled for both principals when their module is off. Every url is
absolute — built from the server's public origin, because the phone has no origin of its own to
prefix — and is the exact string to encode in the QR.
GET /review-links → { links, pages }:
links[]— the caller's own personal review links (ReviewLink.userId= the token's user):{ id, label, code, pageId, pageName, url, isActive, live, stats }.url=<origin>/<company>/reviews/<token>?ref=<code>— the attributed address — ornullwhile the page has never been made public.liveis the one state a QR is worth showing in (link active and page public); the web card says "Not active" for anything else. No permission is asked for these on a user token — being an active member is the grant, as on/marketing/my-links, because the people this exists for hold nomarketingpermission and the query is filtered by their own id. A company key has no person →links: [].pages[]— the company's review pages, the Reviews hub's read:{ id, name, url, publicEnabled, platformNames, linkCount, stats },url= the plain, un-attributed page address. A user token gets them only withmarketing:view(an empty array otherwise — not an error, the links still come); a key needspublic:view.statson both = this month so far, in the company's timezone (opens,visitors,rated,averageRating,sentOut,lowRatings,feedback) — the same window and the same rollup as the web (currentMonthFilter+summarizeEvents): resets on the 1st, counts events per tap, a visitor who arrived with no?ref=counts for the page and for nobody.
GET /quick-links → data[] of the company's quick-link pages, in creation order:
{ id, name, slug, title, isPublic, url, avatarUrl, buttonCount }. url = <origin>/<company>/<slug>;
isPublic: false pages ride along (their address is dead until an operator turns them on — the
phone should say so rather than not list them); buttonCount = the live LINK blocks a visitor
sees (hidden buttons and headings excluded); avatarUrl is a public-tier file, no signature
needed. A user token needs marketing:view (the Marketing list's own guard), a key public:view.
Wiki
wiki scope (T-463) — the pages of the company's internal knowledge base, for importing existing
documents (Notion, Google Docs, another tool), external agents and scripts. Every call is also gated
by the wiki plan module: with the module off the answer is 403 module_disabled for a key and
for a user token alike (the web shows the upsell in the same place).
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/wiki/pages |
wiki:view |
| POST | /api/v1/wiki/pages |
wiki:create |
| GET | /api/v1/wiki/pages/:id |
wiki:view |
| PATCH | /api/v1/wiki/pages/:id |
wiki:edit; wiki:delete for archived |
A user token needs the role's wiki.<action> instead — the same boxes the web's Wiki checks.
Another company's page id is 404 not_found.
Private pages (T-796). A page — and with it its whole subtree — can be private in the app
(visible to chosen people and roles, the page's creator and owner, and company administrators). A
key has company-level trust and lists, reads and edits private pages like any other (see
Trust model). A user token sees exactly what the app shows that member: a hidden
page is missing from the list, 404 not_found on GET/PATCH (the same answer as a missing id), and
422 "Parent page not found." as a parentId; a link inside a visible page that points to a hidden
one comes back with the label "Private page" in contentJson and contentHtml. Access itself is
changed only in the app — the API has no field for it. There is no DELETE: a page is archived (with its
subpages) and restored through PATCH {archived}, exactly like the app's Archive / Undo, and images
cannot be uploaded over the API yet.
The page record
| Field | Type | Notes |
|---|---|---|
id |
string | |
parentId |
string | null | null = a top-level page. Pages nest at most 6 levels deep. |
position |
number | Order among siblings, ascending. Not unique and not contiguous — sort by it, never index by it. |
title |
string | 1–200 characters. |
slug |
string | The page's address in the app: /wiki/<slug>. Minted from the first title and kept on rename, so links keep working. |
icon |
string | null | Read-only over the API. |
archivedAt |
ISO datetime | null | Set on archived pages. |
createdAt |
ISO datetime | |
updatedAt |
ISO datetime | When anything on the page last changed — a rename, a move, an archive included. Not the lock token. |
contentUpdatedAt |
ISO datetime | When the body last changed — the lock token a content PATCH sends back as expectedUpdatedAt; see Editing content. |
contentJson |
object | The page body as a ProseMirror (Tiptap) document — { "type": "doc", "content": [...] }. |
contentHtml |
string | Derived by the server from contentJson and sanitised. Read-only; only on the single-page GET/POST/PATCH. |
contentText |
string | Plain text derived from contentJson (what search indexes). Read-only. |
List — GET /api/v1/wiki/pages
The whole tree as a flat, paginated list (?cursor, ?limit ≤ 100, ordered by id for a
stable cursor). Rebuild the hierarchy from parentId and order siblings by position. Rows carry
the fields above without any content by default:
| Param | Effect |
|---|---|
include=content |
Adds contentJson and contentText to every row. HTML is never listed — fetch a page for it. |
archived=true |
Returns only archived pages (instead of the live ones, never a union — as for tasks). |
Get one — GET /api/v1/wiki/pages/:id
The full record, content and HTML included. An archived page still resolves (archivedAt set).
Create — POST /api/v1/wiki/pages
| Field | Required | Notes |
|---|---|---|
title |
yes | Trimmed; 1–200 characters. |
parentId |
no | Create under this page (appended as its last child). Omitted/null = top level. |
contentJson |
no | The body; omitted = an empty page. |
There is no templateId yet — creating from a template arrives together with wiki templates; any
field not listed here is 422 validation_failed.
201 with the full record. The body passes the same trust boundary as the editor's save: it is
validated against the wiki schema, cleaned (unknown attributes dropped) and the HTML and text are
derived on the server — HTML is never accepted from a client. A body that fails is
422 validation_failed with the reason as message (and under details.contentJson), and no page
is created. The rules a body must meet:
- only the editor's node and mark types (paragraph, heading 1–4, lists, task list, table, code block, blockquote, details, mention, link, …) — an unknown type is refused;
- links start with
http://,https://ormailto:; - images must be wiki uploads (
/uploads/wiki/…) — an external ordata:image is refused, which is why an importer should drop images for now; - a
fileAttachmentnode (attrs: { src, name, size?, mime? }, an attached PDF/Office/TXT/CSV/ZIP file) must also point at a wiki upload — any othersrcis refused with "Files must be uploaded to the wiki …";nameis required (cut to 200 characters), a malformedsize/mimeis dropped. Files cannot be uploaded over the API yet, so an importer can only keep cards it read from a page; - at most 512 KB of JSON, 20 000 nodes, 40 levels of nesting;
- a
mentionof anyone who is not an active member of the company becomes plain@nametext.
A parent from another company, or one that is archived, is 404 not_found; a parent already at
depth 6 is 422 ("Pages can be nested at most 6 levels deep."). A key is not a person: a page it
creates has no author (createdById null) and the page history names the key.
Update — PATCH /api/v1/wiki/pages/:id
Any subset of:
| Field | Notes |
|---|---|
title |
Renames; the slug stays. |
parentId |
Moves the page (with its subpages) to the end of the new parent's children; null = top level. Into itself or one of its own subpages, or deeper than 6 levels, is 422 with the app's sentence; an unknown/foreign/archived target is 422. With a user token, moving the page out of a private section it sits under (to the top level or anywhere that section does not cover) needs the right to change that section's access — its creator, owner or a company admin — else 403 forbidden; the same holds for archived: false when the page's section is gone and it would come back at the top level. A key may (Trust model). |
contentJson |
Replaces the body — requires expectedUpdatedAt. Same rules as on create. |
expectedUpdatedAt |
The page's contentUpdatedAt as you last read it (the field keeps its original name). Required with contentJson; ignored on a patch without contentJson. |
archived |
true archives the page and its whole subtree; false restores it with the subpages archived together with it (a parent that is gone sends it to the top level). Needs wiki:delete. |
All fields of one body are applied in one transaction: a refused step rolls back the others, so a
PATCH is all or nothing. The response is the full record — its contentUpdatedAt is your next token. It is
read inside that same transaction, so it is always the token of your write: if someone saves the
page a moment later, your next contentJson PATCH gets 409 instead of overwriting their save.
A body with both an edit and archived needs wiki:edit and wiki:delete, checked in one go.
An archived page is read-only: a PATCH that edits it without archived: false in the same body is
409 conflict ("This page is archived — restore it before editing"). { "archived": true } on an
already archived page is a no-op 200.
Editing content
contentJson replaces the whole body, so two writers must never overwrite each other silently. The
server compares expectedUpdatedAt with the page's current contentUpdatedAt inside the write
itself: if anyone (the web editor, another script) saved the body since you read it, the answer is
409 conflict — "Page was changed by — reload to continue" — and nothing in the body
is applied. Read the page again, merge, and retry with the new contentUpdatedAt.
Only a body write moves contentUpdatedAt. A rename, a move, a reorder of the siblings or an
archive/restore moves updatedAt but not the token, so they never make a pending body save stale —
and a PATCH without contentJson needs no token at all (one sent anyway is ignored). Before
September 2026 the token was updatedAt: a client that still sends updatedAt gets a 409 on its
first content save after any structural edit — switch it to contentUpdatedAt.
| Status | code |
When |
|---|---|---|
| 403 | insufficient_scope |
Missing wiki:<action> (or the role's wiki.<action>); archived needs wiki:delete. |
| 403 | module_disabled |
The wiki module (or, for a key, api) is off for the company. |
| 404 | not_found |
The page is not yours / does not exist. |
| 409 | conflict |
A contentJson PATCH whose expectedUpdatedAt is not the current contentUpdatedAt, or an edit of an archived page. |
| 413 | payload_too_large |
The body is over 2 MB (the page content itself is capped at 512 KB compact JSON — the rest is room for indentation). |
| 422 | validation_failed |
Bad fields, a refused contentJson, contentJson without expectedUpdatedAt, a tree refusal (cycle, depth), a parentId that is missing, archived or not yours ("Parent page not found."). |
Example: import a tree
KEY="dw_live_…"
API="https://donewellapp.com/api/v1"
# 1. A top-level page with a body.
ROOT=$(curl -s -X POST "$API/wiki/pages" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"title":"Field handbook","contentJson":{"type":"doc","content":[
{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Before you start"}]},
{"type":"paragraph","content":[{"type":"text","text":"Check the equipment list."}]}]}}' \
| jq -r .data.id)
# 2. Children under it — created in the order they should appear.
for T in "Safety" "Chemicals" "Customer etiquette"; do
curl -s -X POST "$API/wiki/pages" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"title\":\"$T\",\"parentId\":\"$ROOT\"}" > /dev/null
done
# 3. Later: update a body with the lock token from the last read.
PAGE=$(curl -s "$API/wiki/pages/$ROOT" -H "Authorization: Bearer $KEY")
curl -s -X PATCH "$API/wiki/pages/$ROOT" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"expectedUpdatedAt\":\"$(echo "$PAGE" | jq -r .data.contentUpdatedAt)\",
\"contentJson\":{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Revised.\"}]}]}}"
# 4. Read the whole tree back, bodies included.
curl -s "$API/wiki/pages?limit=100&include=content" -H "Authorization: Bearer $KEY"
Mobile devices
User tokens only. These endpoints exist for the person behind the mobile app: they register the
Expo push token of the device the app is signed in on, so notifications reach the phone as a native
push (see features/notifications.md). A
company API key has no device — it answers 403 user_token_required before any lookup. No
permission is required beyond being an active member (the user-token checks —
signature, tokenVersion, membership, suspension, the per-device rate limit — all apply).
| Method | Path | Principal |
|---|---|---|
| PUT | /api/v1/mobile/devices |
user token |
| DELETE | /api/v1/mobile/devices/:deviceId |
user token |
| PUT | /api/v1/mobile/devices/:deviceId/call-receiver |
user token + calls.receive (see Calls) |
| DELETE | /api/v1/mobile/devices/:deviceId/call-receiver |
user token |
| PUT | /api/v1/mobile/shift |
user token (+ calls.receive to turn ON, see Calls) |
PUT /api/v1/mobile/devices — register or refresh this device. Body (strict):
{ "deviceId": "8f1c…", "platform": "ios", "expoPushToken": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]" }
deviceId— the app's stable install id, the same value the access token carries as itsdeviceIdclaim (the one sent toPOST /api/v1/auth/mobile/login). A body naming another device is403 device_mismatch: a push token belongs to the install that obtained it.platform—ios|android.expoPushToken— must look like an Expo token (ExponentPushToken[…]/ExpoPushToken[…]), else422 validation_failed.
It is an upsert on (user, deviceId): calling it again after a token rotation, a reinstall or a
DeviceNotRegistered disablement updates the same row (token replaced, lastSeenAt bumped,
disabledAt cleared, the row's company set to the token's company). Two phones of one user are two
rows. Another user's rows naming the same expoPushToken or the same deviceId are deleted first
(T-597): a handset holds one signed-in user, and a previous user who signed out with no signal would
otherwise keep receiving notifications on this user's lock screen. The caller's own rows are never
touched. Response 200 { data: { deviceId, platform, lastSeenAt, createdAt } }. A row created
earlier by PUT …/call-receiver without an Expo token (T-1064, below) is the same (user, deviceId)
row — this call fills its expoPushToken and leaves the VoIP token alone.
Push goes only to a device with a live session (T-597): the fan-out skips a row unless the user
has a current (unrevoked, unreplaced, unexpired) refresh token for that deviceId minted at the
user's current tokenVersion. Logout, a refresh-token replay, a password reset/change and a force
logout therefore all stop pushes immediately — the lost phone does not have to refresh first. Logout
also stamps the row disabledAt; the next login's PUT revives it.
DELETE /api/v1/mobile/devices/:deviceId — stop pushes to one of the caller's own devices
(this one on sign-out, or a sibling in a "sign out everywhere" flow). 204 when removed; 404 not_found when the id is not one of the caller's devices — another user's device is indistinguishable
from a missing one.
What the fan-out sends (data of every Expo message): { url, companyId, kind } — the in-app path,
the notification's company (a user may belong to several; the app switches before opening url),
and the notification type (task.assigned, message.inbound, …).
Calls
Telephony for the phone (Oct 2026, T-768). User tokens only — a call rings a PERSON (their phone
or their softphone), and a company key is not one: 403 user_token_required before any lookup. Every
endpoint also needs both plan modules, calls and inbox (403 module_disabled) — except the
shift switch (PUT /mobile/shift, below), which checks only calls and only to turn ON. No test or
sandbox mode: POST /calls places a real, billable call.
| Method | Path | Role grant |
|---|---|---|
| POST | /api/v1/calls/start |
conversations.create |
| POST | /api/v1/calls |
conversations.create |
| GET | /api/v1/calls/:id/recording |
conversations.view |
| POST | /api/v1/calls/softphone-token |
calls.receive |
| PUT | /api/v1/mobile/devices/:deviceId/call-receiver |
calls.receive |
| DELETE | /api/v1/mobile/devices/:deviceId/call-receiver |
— (any member, own devices) |
| PUT | /api/v1/mobile/shift |
calls.receive to turn ON; OFF — any member |
POST /api/v1/calls/start — the Call button (T-1065): the person's own outbound mode decides how
the call connects, exactly as the web Call button (capabilities.outboundMode announces it). Body
(strict): { "customerId"?: "…", "number"?: "…", "conversationId"?: "…", "fromNumberId"?: "…" } —
one of customerId / number is required (422 validation_failed). With customerId, number
picks one of the client's own numbers (default: their primary); without it, number is anything
typed on a keypad — an unknown full number is saved as a Call Lead client (when the role may add
clients and the plan allows; otherwise the call proceeds and notice says why it was not saved), a
short number (911, an extension) is dialed as typed in softphone mode. conversationId is a hint, as
below; fromNumberId (caller-id line) defaults to the thread's line, else the default voice line.
Two answers, both 201:
{ data: { mode: "CALLBACK", callRecordId, conversationId, customerId, created, notice } }— thePOST /api/v1/callsbridge has started ringing the person's own phone.{ data: { mode: "SOFTPHONE", number, callerNumber, clientState, customerId, conversationId, created, notice } }— nothing is dialed by the server: the app dialsnumberwith the Telnyx SDK'snewCall,callerNumberas caller id andclientStatepassed through untouched. The state is signed for this person, client and thread — it is what files the call as an outbound call event on the client's thread with the person as its caller. Calling from a thread claims it for the person, as the bridge does.customerId/conversationIdmay benull(a typed number with no client; no matching thread — the call then lands in the client's thread on that line, created if needed).
created — a lead was saved by this call. Refusals: a mode that is not ready 409 with the code
and sentence of capabilities.outboundMode (softphone_not_enabled, callback_number_required,
calling_unavailable, outbound_calling_disabled, no_voice_line) — the server never switches to
the other mode, and nothing (no lead, no call) is saved; 409 outbound_profile_missing — the
company's Telnyx softphone connection has no Outbound Voice Profile and none could be attached (the
server attaches the Call Control app's profile, or the account's only one, by itself); unknown or
foreign customer 404 not_found; 422 no_phone / number_required / sample_client /
invalid_line (not an active line of this company) / callback_needs_client (call-back mode to a
number that is not, and could not be saved as, a client); 502 call_failed (Telnyx refused the
bridge). Role grant conversations.create (403 insufficient_scope).
POST /api/v1/calls — click-to-call, the web call-back mode's own core (always call-back,
whatever the person's capabilities.outboundMode says). Body (strict):
{ "customerId": "…", "toNumber"?: "+1…", "conversationId"?: "…", "fromNumberId"?: "…" }. The
server rings the caller's own phone first (their call-back number, Settings → Call on the web)
and dials the customer only once they answer — two linked call records, recording per the company
setting. toNumber must be one of the customer's numbers (default: their primary). conversationId
is a hint: kept only when the thread is this company's, this customer's and the dialed number's;
anything else is ignored and the call goes out unlinked — never an error. fromNumberId (caller-id
line) defaults to the thread's line, else the company's default voice line (the same rule the web
browser mode uses), else the company default line. Response 201 { data: { callRecordId, conversationId, status: "RINGING" } } — the call has started ringing the caller; its outcome arrives
as a kind: "call" item on the thread. Refusals: unknown or foreign customer 404 not_found; 422
no_phone / invalid_line (not an active line of this company) / sample_client /
callback_number_required (the caller has no call-back number); 409 calling_unavailable (Telnyx or
outbound calling not set up for the company); 502 call_failed (Telnyx refused the dial). Calling
is not gated on the shift — the web Call button isn't either.
GET /api/v1/calls/:id/recording — the call's recording (the conversation, or the caller's
voicemail) as audio, the same stream the web player gets (T-1055). 200 with Content-Type: audio/mpeg (or audio/wav) and Accept-Ranges: bytes; send a Range header and it is forwarded,
answering 206 with Content-Range — so a player can seek. Cache-Control: no-store; the URL is
durable (the server resolves a fresh Telnyx download link on every request), but it needs the
Authorization header, so hand the player the header rather than a bare URL. Use it when the call
item says hasRecording: true. Refusals: a call of another company, an unknown id, or a call
without a recording 404 not_found; Telnyx has not produced the file yet 404 recording_not_ready;
Telnyx or the download failing 502 recording_unavailable. The role must grant
conversations.view (403 insufficient_scope).
POST /api/v1/calls/softphone-token — a short-lived Telnyx WebRTC login JWT for this install's
own softphone credential (minted on the first request from the device, stored as the install's SIP
ring destination; the browser keeps the person's credential): 200 { data: { token, onShift } }, Cache-Control: no-store. Never the SIP
password, credential id or SIP username — the SDK logs in with the token alone. The role must grant
calls.receive (403 insufficient_scope); being on shift is not required (onShift tells the
app whether alerts reach this person now). 409 softphone_not_enabled when the person has no
softphone credential yet (enabled on the web, Settings → Call); 409 calling_unavailable without
Telnyx; 502 token_failed when the mint fails.
PUT /api/v1/mobile/devices/:deviceId/call-receiver — { "voipPushToken": "…", "platform"?: "ios" | "android" } (strict — any other key or platform is 422 validation_failed): this install
takes calls on the softphone. Stores the VoIP push token — Android FCM, iOS PushKit, implied by
the device row's platform — on the device row registered by PUT /api/v1/mobile/devices. An
install with no row yet — it never obtained an Expo token (notifications denied, an emulator) —
gets one created when the body carries platform (T-1064): expoPushToken: null, the token's
company, the VoIP token, lastSeenAt; another user's rows on the same deviceId are deleted and
their install softphone on it revoked first, as PUT /mobile/devices does. Such a row receives VoIP
pushes only — the Expo fan-out skips it. Without platform a missing row is 404 not_found (older
clients); on an existing row platform is ignored (the row keeps its own). Only the access token's
own device (403 device_mismatch); needs
calls.receive and a softphone credential (409 softphone_not_enabled). Another user's row holding
the same token is cleared (shared handset, the T-597 rule). Response 200 { data: { deviceId, platform, voipProvider: "fcm" | "pushkit", voipRegisteredAt } }. The app also hands the token to the
Telnyx SDK at login; Telnyx wakes the app through the push credential configured on the company's
Credential Connection (a Telnyx portal step). Logout (POST /auth/mobile/logout) and
DELETE …/call-receiver (204, no grant needed — stepping down must work after a revoke) clear it;
deleting the device removes the row. All three also delete the install's own Telnyx credential
(T-866): Telnyx binds the push token to the credential, so this is what stops a signed-out phone
ringing; the next softphone-token mints a fresh one. Another user signing in on the same deviceId
— a successful POST /auth/mobile/login or /auth/mobile/google, PUT /mobile/devices, or a
softphone-token — revokes the previous user's credential on it, whether or not the new user has a
softphone, and an install whose mobile session has ended (revoked,
expired, tokenVersion bumped) is not rung even before any of that.
PUT /api/v1/mobile/shift — { "onShift": true | false } (strict, a non-boolean is 422 validation_failed): the caller switches their OWN shift for the token's company — the twin of the
web header switch (T-777). Being on shift is what makes incoming calls alert this person. Turning
ON needs calls.receive on the role (read from the role row on every request, 403 insufficient_scope with the message "Your role can't take incoming calls — ask an administrator to
enable it.") and the calls module (403 module_disabled); the inbox module is not required.
Turning OFF is never gated — it works after a revoke or with the module off, so a phone can
always stop ringing. Response 200 { data: { onShift, onShiftSince } } (onShiftSince ISO or
null), Cache-Control: no-store. Idempotent: a repeated ON keeps the original onShiftSince.
Only the membership in the token's company changes; other companies' shifts are untouched. Other
people's shifts are not settable over the API (web Settings → Calls → Who takes calls, admin.edit).
Call pushes ride the ordinary Expo fan-out (Mobile devices): kind: "call.incoming" (push only, no feed row) while a call rings, kind: "call.missed" after a miss, both
with url: "/conversations?id=<threadId>" (/calls for a call with no thread). Recipients: members on
shift with calls.receive and conversations.view, plus the thread's owner; a missed call with
nobody on shift and no owner goes to every inbox viewer. A signed-out device is never pushed.
Search and recent
The phone's global search (Oct 2026). Both endpoints open for a caller holding view on any of
clients, requests, quotes, jobs, invoices (a key: any of those scopes; a user token: any of
those role permissions) and answer ONLY the types it holds — a type it cannot read is absent from
the answer, never a 403. Sample (demo) records are excluded; plan-locked clients are not found.
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/search |
view on any of the five |
| GET | /api/v1/recent |
view on any of the five |
GET /search?q=&types=&limit= — q (required, trimmed, ≤200 characters); types — a comma
list of clients,requests,quotes,jobs,invoices (default all; an unknown name is a 422); limit —
per group, 1..20 (default 5). A client matches by every name word (anna smith finds Anna Smith),
or by email or phone; a request by title, contact name or its client's name; a quote / job /
invoice by title or client name; a digit run (42, #42) also matches the document NUMBER exactly.
Response — one group per type asked for AND readable, newest first, an empty group kept:
{ "data": { "clients": [hit], "requests": [hit], "quotes": [], "jobs": [hit], "invoices": [] } }
GET /recent?limit= — the newest records over the readable types, merged newest first, limit
1..50 (default 20): { "data": [hit, …] }.
A hit: { id, type, title, subtitle, number, status, customerId, createdAt } — type is the
group name (clients, …); title is the client's name or the document title ("Quote #5" when it
has none); subtitle the client's email / phone, or the document's client; number the document
number (null for a client); status the record's own status enum; customerId the client (the
client's own id for a client hit). No money. "Newest" is by creation (createdAt): the records
carry no last-modified stamp.
Team
GET /api/v1/team — the company's active people, for the visit crew picker and the
assignee= filter of GET /visits. Scope visits:view (user token: schedule:view or
jobs:view). The same list the app's pickers offer and the server validates crew writes against
(membership and account both active), alphabetical. Not paginated (pagination.nextCursor: null).
Row: { userId, firstName, lastName, email, roleName }.
Crews
GET /api/v1/crews — the crew picker of the job and visit forms (Oct 2026). Same scope and
grants as GET /team: visits:view (user token: schedule:view or jobs:view). The
same list the app's crew pickers read, in the company's crew order. Not paginated.
Row: { id, name, color, isActive, leadUserId, memberUserIds } — leadUserId / memberUserIds
name only people the company can currently assign (see GET /team), and memberUserIds
never repeats the lead. Inactive crews are included with isActive: false, so a job that still
holds one can show it; offer only active crews for a new choice, and send a stored inactive crew
back unchanged rather than clearing it.
Tax rates
GET /api/v1/tax-rates — the tax picker of the quote / job / invoice create forms (Oct 2026)
and of the expense form (T-921). Same scope as Catalog; a user token needs the catalog
grants or expenses:view / create / edit. Your active, non-archived rates in the order
set in Settings → Taxes. Not paginated.
Row: { id, name, rate, isDefault, components[] } — rate is a FRACTION (0.05 = 5%), like a
document's taxRate. components[] = { id, kind, name, rate, recoverable } in order — what the
rate is made of (GST 5% + PST 7%); an expense's taxAmounts are keyed by these ids.
meta: { defaultTaxRateId, taxInclusiveDefault } — what the app's new-document forms pre-fill:
the default rate's id (null when none is marked default) and whether prices include tax by default
(taxInclusive omitted on create takes the same default).
Catalog
GET /api/v1/catalog?search=&priceListId=&cursor=&limit= — the line-item picker: your
active products and services in catalog order (category, then position), priced the way the
app's line editor pre-fills them. Scope catalog:view; a user token needs products:view, or
create/edit on quotes, jobs or invoices, or schedule:edit.
| Param | Notes |
|---|---|
search |
Case-insensitive substring of the name or description. |
priceListId |
Price the items from this list (sale prices applied). Honoured while the company's plan has Price Lists; without it the company has one list and that is used. Absent = the default list. Not one of your lists → 422. |
cursor, limit |
As in Pagination. |
Row: { id, name, description, type, categoryId, categoryName, unit, taxable, unitPrice, unitCost, qtyDecimalPlaces } — type is SERVICE / PRODUCT; unitPrice is 0 for an item the list does
not price. qtyDecimalPlaces is the qty precision the app's line editor uses for this item (0 =
whole numbers, the same for every item today); a create posting a finer qty on a line linked to the
item is refused (see the line item rules).
unitPrice / unitCost are null for a user token whose role lacks pricing.view; unitCost is
also null without costs.view ("Costs & margins", T-821). An API key always gets both.
Examples
Set your key once:
export DW_KEY="dw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DW_BASE="https://donewellapp.com/api/v1"
List customers (first page + next page)
curl -s "$DW_BASE/customers?limit=25" -H "Authorization: Bearer $DW_KEY"
# → { "data": [...], "pagination": { "nextCursor": "clx9...", "hasMore": true } }
curl -s "$DW_BASE/customers?limit=25&cursor=clx9..." -H "Authorization: Bearer $DW_KEY"
Create a customer with phones
curl -s -X POST "$DW_BASE/customers" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"status": "LEAD",
"phones": [{ "phone": "416-555-0100", "label": "Mobile", "isPrimary": true }],
"emails": [{ "email": "ada@example.com" }]
}'
# → 201 { "data": { "id": "...", "firstName": "Ada", ... } }
Create an invoice with line items
curl -s -X POST "$DW_BASE/invoices" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{
"customerId": "clx_customer_id",
"dueDate": "2026-08-15T00:00:00.000Z",
"lineItems": [
{ "name": "Carpet cleaning — 3 rooms", "qty": 1, "unitPrice": 240, "isTaxable": true },
{ "name": "Stain treatment", "qty": 2, "unitPrice": 35 }
]
}'
# → 201; subtotal/taxAmount/total are computed server-side.
Update (PATCH) a customer — replace phones
curl -s -X PATCH "$DW_BASE/customers/clx_customer_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "ACTIVE", "phones": [{ "phone": "416-555-0199", "isPrimary": true }] }'
Work a kanban card: poll Ready → claim it → comment
# 1. What is waiting in "Ready"? (unknown column name ⇒ [], not the whole backlog)
curl -s "$DW_BASE/tasks?boardId=clx_board_id&column=Ready&limit=5" \
-H "Authorization: Bearer $DW_KEY"
# → { "data": [ { "id": "clx_task_id", "title": "Fix the van",
# "board": { "id": "clx_board_id", "name": "Ops" },
# "column": { "id": "...", "name": "Ready", "isDone": false, "category": "UNSTARTED" },
# "labels": [...], "archived": false, ... } ], ... }
# 2. Read the full card — notes, checklist, attachments, custom fields.
curl -s "$DW_BASE/tasks/clx_task_id" -H "Authorization: Bearer $DW_KEY"
# 3. Claim it: move to "In progress" by name (board inferred from the card).
curl -s -X PATCH "$DW_BASE/tasks/clx_task_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{ "column": "In progress" }'
# → detail DTO with the new column; a `moved` activity entry is logged under the key's name.
# 4. Report back in the thread (notifies assignee + creator).
curl -s -X POST "$DW_BASE/tasks/clx_task_id/comments" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{ "body": "Picked this up — starting now." }'
# → 201 { "data": { "authorId": null, "authorName": "Claude", "body": "Picked this up…" } }
# 5. Fill the card in — one PATCH: label it, write a named section, tick off the plan,
# set a custom field. Nothing here needs a block/item/option id.
curl -s -X PATCH "$DW_BASE/tasks/clx_task_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" \
-d '{ "labels": ["tasks-ux"],
"blocks": [ { "title": "Acceptance Criteria", "content": "The van starts." } ],
"checklist": [ { "text": "Order the part", "done": true },
{ "text": "Fit it" } ],
"customFields": { "Mode": "Ship it", "Est. hours": 3 } }'
# 6. Done: moving into a column that counts as done (`isDone`, and not `category: CANCELED`)
# also flips status to COMPLETED.
curl -s -X PATCH "$DW_BASE/tasks/clx_task_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" -d '{ "column": "Review" }'
# 7. Put it away when the work is filed elsewhere — and bring it back where it was.
curl -s -X PATCH "$DW_BASE/tasks/clx_task_id" \
-H "Authorization: Bearer $DW_KEY" \
-H "Content-Type: application/json" -d '{ "archived": true }'
Error example (missing scope)
curl -s -X POST "$DW_BASE/invoices" -H "Authorization: Bearer $DW_KEY_READONLY" \
-H "Content-Type: application/json" -d '{ "customerId": "..." }'
# → 403 { "error": { "code": "insufficient_scope",
# "message": "This API key is missing the required scope: invoices:create" } }