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), and payments.

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
    
  • 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).

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": ["Required"] } } }

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.
403 insufficient_scope The key lacks the required resource:action scope.
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, or a task number that could not be allocated (retry the same request).
409 duplicate A board label with that name already exists on the board.
409 duplicate_candidates POST /tasks found open cards with a very similar title — see Duplicate guard. Repeating the request unchanged returns the same 409.
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).
  • Invalid-key throttling: 20 failed auth attempts per IP per 15 minutes also return 429 (brute-force / key-enumeration guard). Only failures count.

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 — only payments:view can be granted.

Resource Actions available
clients view, create, edit, delete
requests view, create, edit, delete
quotes view, create, edit, delete
jobs view, create, edit, delete
invoices view, create, edit, delete
tasks view, create, edit, delete
payments view

A call missing the needed scope returns 403 insufficient_scope.

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 app sessions only.
  • Grant each integration the narrowest scope set it needs, and revoke keys that are no longer used.

Customers

clients scope. (Customers are the "clients" resource internally.)

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

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? }.

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).

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. Requests are lead-capture only over the API — no line items, no totals.

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

List filters: status — one of RequestStatus.

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).


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 }] 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

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 only what the company itemizes — a fee configured "not shown on documents" is inside total but absent here (its money is part of the gap the Subtotal absorbs on the printed document); and fees are read-only, snapshotted from the customer's city at write time like the rest of the pricing snapshot.

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

List filters: status (QuoteStatus), customerId.

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

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).

Response: id, quoteNumber, title, status, customerId, propertyId, notes, discountType, discountValue, taxRateId, taxInclusive, subtotal, taxAmount, minimumCharge, fees[], total, createdAt, lineItems[] (id, productServiceId, name, description, qty, unitPrice, total, isTaxable).


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
DELETE /api/v1/jobs/:id jobs:delete

List filters: status (JobStatus), customerId.

Create / patch body

Field Type Create Notes
customerId id required Optional on PATCH; must be yours.
title string required Optional on PATCH.
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.
quoteId id | null optional Must be yours.
notes string | null optional
status enum JobStatus optional
startAt ISO date-time | null optional
endAt ISO date-time | null optional
assignedToId id | null optional Lead technician; must be a company member.
discountType enum DiscountType | null optional
discountValue number ≥ 0 | null optional
taxRateId id | null optional
lineItems array of line item optional Defaults to [] on create.

Semantics

  • assignedToId is the lead technician. The API accepts no helper technicians or per-job commission overrides — only the lead is synced.
  • Same as quotes: lineItems replace wholesale; subtotal/taxAmount/total are server-computed; fees are snapshotted from the customer's city.

Response: id, jobNumber, title, status, customerId, propertyId, quoteId, assignedToId, startAt, endAt, notes, discountType, discountValue, taxRateId, taxInclusive, subtotal, taxAmount, minimumCharge, fees[], total, createdAt, lineItems[].


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

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
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
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.
  • 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).

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, createdAt, lineItems[].


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.
  • Reads/edits cover every task in the company, including cards on private boards the key's creator cannot see in the app — 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.

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}), archived, createdAt, updatedAt.

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}[] 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 whose title 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.

{
  "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"
        }
      ]
    }
  }
}

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.

What is compared and what is scanned:

  • Titles are compared as 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.
  • Only open cards are candidates: 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).
  • The scan covers the 500 most recently created open cards of the company. This is an advisory gate, not an invariant: a company with a very large open backlog can slip an old duplicate past.
  • At most 5 candidates are listed, best match first.

?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 follow the same site-absolute rule as card attachments.

  • 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.

Attachment URLs

attachments[].url is site-absolute (/uploads/tasks/<taskId>/<file>), not a full URL — prefix your instance origin (https://donewellapp.com/uploads/…). The files are served without auth, so treat a task attachment URL as a shareable secret-by-obscurity link.

This is a recorded exception, not an oversight. Most /uploads directories require a signed-in session; tasks/<id> is one of the few deliberately left open, precisely because this contract promises you a URL you can fetch with nothing but the link — pasted into your own tool, embedded in an <img>, or handed to a viewer who holds no key, none of which can carry an Authorization header. Closing it would break working integrations one broken image at a time.

The filename is a timestamp plus a v4 UUID (122 bits of CSPRNG), so the URL is the whole credential: it is unguessable, it is not revocable, and it does not expire. Do not paste one anywhere you would not paste the file itself.

Put plainly: a task attachment URL is a capability, and the only way to withdraw it is to make the object stop existing. Two consequences worth stating, because neither is visible from the endpoint:

  • DELETE …/attachments/:id removes the row, not the file (see above). The link keeps working after the attachment is gone from the card.
  • The per-company ownership check that guards the other upload directories does not apply here. It answers "which company owns this file", and a public file has no owner to check against.

If you need attachments that are NOT world-readable by whoever holds a link, do not put them on a card. A future closed variant of this contract would have to be a new field or a new version, never a change of behaviour on attachments[].url.


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 app sessions, 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/: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), pinned on labels (since T-378 the labels render in the card HEADER beside the number, never as a panel row — hidden and required still apply to it, and a pin stored before the move reads back as pinned: false), and pinned together with hidden on the same row. 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" } }

Payments

payments scope — read-only. Money moves through Stripe, never a raw API write.

Method Path Scope
GET /api/v1/payments payments:view
GET /api/v1/payments/:id payments:view

List filters: invoiceId.

Response: id, invoiceId, amount, tipAmount, currency, method, status, paidAt, notes, createdAt. (Stripe/external ids, processing fees, and card details are never exposed.)

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.


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" } }