DoneWell

DoneWell Public REST API v1

Read and write your company's DoneWell data over HTTP. Create a scoped key in the app under Settings → Developer → API Keys.

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 production https://donewellapp.com/api/v1.
  • Authentication: send your key as a Bearer token on every request:
    Authorization: Bearer dw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    
    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.
  • 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 — take multipart/form-data instead (POST /expenses accepts 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_limited with a Retry-After header (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 429 whether 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:delete is 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 to tasks:edit — so a key granted view/create/edit answers 403 insufficient_scope there 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 is tasks: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 tasks scope 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 wiki scope: 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), claims userId, 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:edit to PATCH /jobs/:id, schedule:view to list visits. A missing permission is 403 insufficient_scope and 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_revoked on the very next request — the token's tokenVersion is compared with the user's, the same signal that ends the user's web sessions. 401 invalid_token is a token that does not verify or has expired. Either way: refresh, or sign in again.
  • No api module: 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 authorId set and the person's name, activity entries name them — where a key leaves authorId: null and 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 back null — subtotal, taxAmount, minimumCharge, total, discountValue, promoDiscountAmount, fees[].amount, amountPaid, balanceDue and each line's unitPrice/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 with 403 prices_hidden; the same call without them works. A dw_live_ key has no role, so this never applies to it.
  • Private boards: the tasks and boards routes 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 from GET /boards, 404 on /boards/:id/**, on a card of it and on that card's comments/activity/attachments, [] for GET /tasks?boardId= / ?columnId=, 422 Column not found as a move target, 404 as a parentId on 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? }

  • deviceId is a stable id the app mints at first install (≤128 chars); deviceName is a label for a future devices list. totp is 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 is 403 account_deactivated only after the right password; a missing and a wrong totp are one 401 two_factor_required (the app shows the code field and retries); an unverified email is 403 email_not_verified; a person with no company is 403 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
        }
      }
    }
    

    companyId is the company the access token acts in — activeCompanyId when it is still an active membership, else the first one — listed first in companies. To act in another company of the list, see switch-company below.

    locale is that company's localization (Settings → Company): the IANA timezone its "today" and instants live in, dateFormat (MM/DD/YYYY · DD/MM/YYYY · YYYY-MM-DD), timeFormat (12h · 24h) and currency (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.

    capabilities is 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 answers 403 module_disabled / insufficient_scope. modules carries 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). permissions lists 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. outboundMode is how this person's in-app Call button connects at that company — mode CALLBACK (their phone rings first) or BROWSER (the web softphone), the company default unless they chose their own under Settings → Calls — and ready whether that mode could place a call right now; when it cannot, refusal is the sentence naming the fix and refusalCode the same code POST /api/v1/calls/start answers as a 409 (softphone_not_enabled, callback_number_required, calling_unavailable, outbound_calling_disabled, no_voice_line, module_disabled) — both null when ready. The Call button uses POST /api/v1/calls/start, which follows this mode; POST /api/v1/calls is always call-back. callbackNumberSet is whether THIS person has a call-back number (Settings → My calling) — false means POST /api/v1/calls will refuse callback_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 answers 503 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_email when Google has not verified the address; 403 email_not_verified when 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. No totp: 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 — refresh and logout work 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 companies list — a technician with two companies switches without signing in again. The answer is the login body without refreshToken/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. companies is 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 next refresh stays 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_required for 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 and tokenVersion are 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 own DELETE /mobile/devices/:id is 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, locale and capabilities for the company the access token acts in — the login body minus tokenType/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 no capabilities. 403 user_token_required for a company API key; 403 account_suspended when 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 of LEAD, 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 (or emails) 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 flagged isPrimary, the first one becomes primary.
  • On PATCH, a null on a non-nullable text column (title, companyName, notes, billing fields) clears it to "". leadSourceId accepts null to 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 of balanceDue over the client's invoices awaiting payment (SENT / AWAITING_PAYMENT / PARTIAL / OVERDUE; drafts, paid and void invoices and gift-card loads do not count). null for a user token whose role lacks pricing.view or invoices:view, and for a key without the invoices:view scope — the web hides the balance behind the same two grants. A number (0 when 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 as classification.tagIds; tags (names) stays for display. Legacy free-text tags have no id.
  • referralCustomerId — the client who referred this one, or null. Written by classification only while the referrals module is on.
  • marketingPhotoConsent — user tokens only. A dw_live_ key never receives the field, and its contact-info PATCH 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 of RequestStatus.
  • customerId — only requests linked to this client. Another company's id is not an error: the list is simply empty.
  • search — case-insensitive substring over contactName, title and the linked client's first name, last name and company name; a bare number (1042 or #1042) also matches requestNumber by equality (42 does not return #142). Combines with the other filters and with cursor/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

  • lineItems replaces wholesale on PATCH: sending the array deletes and rebuilds all lines.
  • Totals are server-computed and read-only: subtotal, taxAmount, total are 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 as minimumCharge + fees[] (see above).
  • A CONVERTED quote is frozen (as in the app): PATCH returns 409 conflict for anything but notes, and status: CONVERTED cannot be set by PATCH — only a conversion writes it.
  • status follows 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 is 409 conflict with the app's sentence (Cannot change status from Draft to Approved) and nothing in the PATCH is written. Sending back the stored status is 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 at CANCELED.
  • /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": "…" }] — not helperUserIds), recurrencePattern. Required with the series they describe — only when type is RECURRING and recurrencePattern is not DOES_NOT_REPEAT: recurrenceEndType; with it recurrenceEndCount (AFTER_COUNT) or recurrenceEndDate (ON_DATE); and recurrenceInterval + recurrenceUnit for CUSTOM. 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: recurrenceDaysOfWeek for CUSTOM with recurrenceUnit WEEK (or null, which repeats weekly); recurrenceMonthlyMode for MONTHLY and for CUSTOM with unit MONTH, and then recurrenceMonthlyWeekdays when the mode is NTH_WEEKDAY, recurrenceMonthlyDays otherwise (DAY_OF_MONTH or null). "" is the "none" of those three lists. The WEEKLY preset reads no weekday list. Optional: recurrenceEndPeriod, and the two go-aheads confirmVisitRemoval / confirmPerVisitExit (below). type: RECURRING with recurrencePattern: DOES_NOT_REPEAT is 422 unless scheduleLater is true; a job made ONE_OFF is moved to invoicingFrequency: ON_COMPLETION in the same save. Saving regenerates the series the way the app does: only future SCHEDULED visits 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 at DONE and CANCELED.
  • /billing — required: invoicingFrequency, billingType, remindToInvoice, splitIntoSchedule, paymentSchedule[]; optional: confirmPerVisitExit. Locked at CANCELED; needs prices (see below). A one-off job cannot be CHANGED to PER_VISIT / MONTHLY (422 on invoicingFrequency; a value it already holds still saves). Turning the split off while an installment invoice that is not VOID still bills it is 422 naming those invoices.
  • /line-items — required: items[] (name, qty, unitPrice, optional productServiceId, description, unit, unitCost, isTaxable, isTextOnly), discountType, discountValue, taxRateId, priceListId, cityId, promoCodeId. Optional: taxInclusive (absent = keep the stored setting) and feeOverride (absent = fees re-resolve from the city). Totals are recomputed by the one server writer. Locked at CANCELED; 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

  • number auto-allocates (max + 1 per company) when omitted, retrying on concurrent collisions. Supplying a number already taken returns 409 conflict.
  • Due date on create follows the app's new-invoice form (Oct 2026): an explicit dueDate always wins (with no paymentTerms, the terms are stored as CUSTOM); otherwise paymentTerms — the body's, else the company's Settings → Invoices terms, else NET_30 — sets the due date from issueDate (today when absent): NET_15 on an issue date of 2026-10-01 is due 2026-10-16. DUE_ON_RECEIPT (and CUSTOM without a date) has no due date. PATCH changes neither implicitly.
  • lineItems replace wholesale; subtotal/taxAmount/total are server-computed; amountPaid/balanceDue/status are recomputed from recorded payments. All are read-only.
  • DELETE is blocked with 409 conflict if the invoice has any payments, payment allocations, or refunds (money records are never destroyed).
  • A PAID invoice is frozen (as in the app): every PATCH, notes and status included, returns 409 conflict. Sending back the stored status unchanged is not an edit.
  • PAID and PARTIAL cannot be set by PATCH, and a PARTIAL invoice cannot be moved off it — both follow the invoice's recorded payments, never the field. Record the money with POST /api/v1/payments and the status follows. Such a PATCH is 409 conflict and nothing in it is written; sending back the stored status is 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/column are null). 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=Redy returns { "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. Threshold 0.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. Threshold 0.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 (a CANCELED column 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 within boardId if given, otherwise within the board the card is already on. A card that is calendar-only and has no boardId in the body → 422 boardId is required to resolve a column by name.
  • columnId is validated against your company (and against boardId when supplied).
  • An unknown column → 422 Column not found. A card that is archived, or belongs to another company → 404.

What a successful move does:

  1. Appends the card at the end of the target column — send position in the same body to put it somewhere else (0 = top).

  2. 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 /tasks seeds from description is 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.

    blocks in 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.

  3. Syncs status against the column's done-ness: into a done column → COMPLETED, out of one → PENDING, same done-ness → untouched. An explicit status in the same body wins over this. Done-ness is isDone except in a CANCELED column, which is never done — moving a card there completes nothing, and moving a completed card there reopens it.

  4. Writes a moved entry 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>"]} is 422, 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 (not 404 — the body is what is wrong, and GET /boards/:id already lists a board's labels, so there is no existence to hide). labels resolves names within that same board; an unknown name is 422, never a silent skip — the opposite of the filter rule, where an unresolved name yields an empty list.
  • A change writes a labels_changed entry 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 /tasks and 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 CANCELED column never satisfies, even with isDone set. Ticking isDone on 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.

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 parentId and 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 id is updated in place (its id survives, so you can address it again next time). text and done are both optional there — {"id": "…", "done": true} ticks an item without re-sending its text;
  • an element without an id is created (text is 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 title is 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 by id and send the new title: {"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 boardId in the same body and it falls back to that board's first column; with no board context it stays calendar-only.
  • An explicit columnId / column in 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 / restored entry to the card feed under the API key's name.

An archived card is otherwise read-only. A PATCH that does not carry archived answers 404 on one — the same answer as a foreign id, so nothing is leaked by trying. GET /tasks/:id still resolves it (with archived: true), and GET /tasks never 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.

  • GET returns newest first (cursor-paginated like every list) — reverse a page to read the thread chronologically. Comments on archived cards are readable.
  • POST takes { "body": "…" } (1–5000 chars) and returns 201 with the created comment. Commenting on an archived card is 404 — 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 commentCount and never the thread, so "did my comment land" is answered here, not by re-reading the card.
  • body may 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-data instead of JSON — a body text field (optional when files are present) plus one or more files fields (≤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 and tasks.view granted. A request that does not qualify gets 404, never 403.
  • Inline images inside block HTML (blocks[].content) and comment bodyHtml keep their bare /uploads/tasks/… src, deliberately unsigned: HTML you PATCH back is stored, and a signed src would 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 returns 404, never 403.
  • Scopes map to REST verbs: read → tasks:view, create → tasks:create, update → tasks:edit, delete → tasks:delete. (In the app, editing custom fields is one tasks.edit action; over the API each verb keeps its own scope.) Everything here fits inside the existing tasks scopes — 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.

fieldSettings deliberately 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 isDone or category does 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:

  1. 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").
  2. If it was the board's last column, they detach to calendar-only (board/column become null on the task DTO; they still show on /schedule).
  3. 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 } (an UNSCHEDULED visit 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. A DONE/CANCELED job is 409 visit_locked; an unknown or foreign job 404; a foreign crew member or a bad window 422. Double-bookings and after-hours windows only warn (meta.warnings). 201 with 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 are 404; a user token off the visit's crew without allVisits.edit is 403 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 an Authorization: Bearer header gets 404. 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 (or requests/) 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 becomes actualStart (start) or actualEnd (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 for complete not before the visit's own actualStart; outside it, or when absent / not an ISO datetime, the server clock is used. Never a 4xx: an untrusted stamp is ignored, not refused.

  • Idempotent, by design for an offline queue. A replay writes nothing to the visit and still answers 200 with the current state: start on an IN_PROGRESS or COMPLETED visit, complete on a COMPLETED one. In particular a start that reaches the server AFTER complete (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 start replay does write, on the crew row, not the visit: with a user token, a start by a member of the crew stamps that member's own crew[].startConfirmedAt — on the first call and on a replay against an IN_PROGRESS visit, because that replay IS the phone confirming a dispatcher's remote Start. It opens the member's GPS shift window (below). Never on a COMPLETED visit, 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.edit may run start, complete, reopen, PATCH and line-items only on visits whose crew it is on (lead or helper); any other visit of the company answers 403 not_assigned and nothing is written. An unknown or foreign id still answers 404.

  • start: SCHEDULED → IN_PROGRESS, actualStart stamped once (never moved by a second call); the job follows to IN_PROGRESS. Refused with 409 visit_locked when the job is DONE / 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 is SKIPPED ("Reopen the visit before starting it."). The messages are the app's own — the technician and the dispatcher read the same sentence.

  • complete: → COMPLETED, actualEnd stamped, actualStart backfilled 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 to REQUIRES_INVOICING once 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 with 409 visit_locked only for UNSCHEDULED and SKIPPED.

  • Each trip is its own interval (T-981). A complete writes the interval of the trip being finished and never touches an earlier one. With no actualStart (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 — otherwise 422 validation_failed (details.workedEnd) and nothing is written. A running clock-in wins over a typed window. Two racing complete calls 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, accuracy metres ≥ 0, speed m/s ≥ 0, heading 0…360, batteryLevel 0…1, activity the OS motion class (≤ 32 chars) — all but lat/lng/recordedAt optional. recordedAt is the device's timestamp of the fix (ISO), not the send time.
  • Response 200 { data: { accepted, duplicates, rejected } }. duplicates = points already stored (dedup on recordedAt per 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 still 200: 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 enter precedes 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 enter while the visit is still SCHEDULED stamps arrivedAt once — answers arrivalMarked: true; every other crossing (a second enter, any exit, an enter on an IN_PROGRESS visit) is recorded and answers arrivalMarked: false. The status is re-checked on the server, never trusted from the phone.
  • Idempotent on (visitId, kind, at) per caller: a replay is 200 with nothing new stored.
  • Response 200 { data: { visitId, kind, at, arrivedAt, arrivalMarked } }. Errors: 404 not_found (unknown id or another company's visit — never 403), 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" + giftCardCode on POST /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 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 — or null while the page has never been made public. live is 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 no marketing permission 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 with marketing:view (an empty array otherwise — not an error, the links still come); a key needs public:view.
  • stats on 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:// or mailto:;
  • images must be wiki uploads (/uploads/wiki/…) — an external or data: image is refused, which is why an importer should drop images for now;
  • a fileAttachment node (attrs: { src, name, size?, mime? }, an attached PDF/Office/TXT/CSV/ZIP file) must also point at a wiki upload — any other src is refused with "Files must be uploaded to the wiki …"; name is required (cut to 200 characters), a malformed size/mime is 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 mention of anyone who is not an active member of the company becomes plain @name text.

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 its deviceId claim (the one sent to POST /api/v1/auth/mobile/login). A body naming another device is 403 device_mismatch: a push token belongs to the install that obtained it.
  • platform — ios | android.
  • expoPushToken — must look like an Expo token (ExponentPushToken[…] / ExpoPushToken[…]), else 422 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 } } — the POST /api/v1/calls bridge 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 dials number with the Telnyx SDK's newCall, callerNumber as caller id and clientState passed 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/conversationId may be null (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" } }