API
The web app, the CLI and any integration use the same versioned REST API.

Base URL: https://work.betterthangood.xyz/api/v1
Authentication
Create a personal access token at Settings → API tokens and send it as a bearer token. Admins see and can revoke every token in the account; members and contractors manage only their own:
curl https://work.betterthangood.xyz/api/v1/contacts \
-H "Authorization: Bearer work_pat_…"
The header is the only accepted method. Tokens are not read from query parameters or cookies.
A token acts as the user who created it, on the account it was created in, with that user’s access.
Creating a token
| Field | Notes |
|---|---|
| Name | A label, for example “Deploy script” |
| Scopes | read, write or both |
| Expires | Defaults to 30 days out |
The token is displayed once, at creation. WORK stores a hash of it and cannot display it again. Replace a lost token by revoking it and creating another.
Scopes
| Scope | Allows |
|---|---|
read | Everything the issuing user can see |
write | Everything the issuing user can change. Includes read |
Reading is open on every plan. Writing needs Pro: a free account cannot issue a write token, and a token issued while the account was on Pro is refused with 402 once it is not (see Billing & plan).
Revoking
Tokens are revoked rather than deleted, so audit log entries keep a valid reference. A revoked token stops working immediately.
Resources
| Resource | Endpoints |
|---|---|
| Contacts | GET/POST /contacts · GET/PATCH/DELETE /contacts/{id} |
| Companies | GET/POST /companies · GET/PATCH/DELETE /companies/{id} |
| Deals | GET/POST /deals · GET/PATCH/DELETE /deals/{id} |
| Projects | GET/POST /projects · GET/PATCH/DELETE /projects/{id} |
| Tasks | GET/POST /tasks · GET/PATCH/DELETE /tasks/{id} |
| Invoices | GET /invoices · GET /invoices/{id} · PUT /invoices/{source}/{external_id} |
| Bills | GET /bills · GET /bills/{id} |
Invoices are imported records of what was billed, so there is no create, update or delete endpoint. Bring one in with a CSV import, or sync it from the accounting system you keep your books in.
Syncing an invoice in
PUT /api/v1/invoices/xero/9c1
{ "invoice": { "number": 91, "status": "sent", "currency": "USD",
"issue_on": "2026-01-02", "due_on": "2026-02-01",
"subject": "January retainer",
"customer": { "external_id": "cus_7", "name": "Widgets Intl" },
"lines": [ { "description": "Design sprint", "quantity": 2,
"unit_price_cents": 150000 } ] } }
The invoice is addressed by the accounting system it was billed in — quickbooks, xero or freeagent — and the id that system knows it by, so sending it twice updates the same invoice rather than adding a second: 201 the first time, 200 after. Lines are replaced whole each time, and a body with no lines leaves the ones already there.
The customer is matched by its own external_id, never by name; one you have not billed before is created from the name you send. A synced invoice stays read-only in the web app, like an imported one, and carries source, external_id, external_url and synced_at when you read it back.
Invoices only. Bills are money you owe out, so you enter them in the web app or bring them in by CSV import — nothing syncs them.
Bills are also read-only through the API. A bill response includes its stored open or paid status, derived overdue display status, reference, project, and computed total. Creating and paying bills remain deliberate actions in the web app.
Task access follows roles: admins and members can list and assign any task; contractors see and change only their own, and a task created by a contractor is assigned to them.
Responses
Responses use one envelope. Lists return an array in the same data key.
{ "data": { "id": 42, "first_name": "Ada", "created_at": "2026-08-27T09:15:00Z" } }
Every record carries id, created_at and updated_at.
Money in responses is an integer number of cents, in value_cents and amount_cents fields, with a currency field alongside.
Reading an invoice
GET /api/v1/invoices/42
{ "data": {
"id": 42, "number": 1001, "status": "sent", "display_status": "overdue",
"currency": "USD", "issue_on": "2026-01-05", "due_on": "2026-02-04",
"subject": "January retainer", "notes": "Thank you!", "sent_at": "2026-01-05T09:00:00Z",
"total_cents": 385000, "company_id": 7, "contact_id": 12 } }
total_cents is computed from the invoice’s lines. display_status reflects an overdue sent invoice; status is the stored value.
Pagination
GET /api/v1/contacts?page=2&per_page=50
Default 25 per page, maximum 100. Values outside the range fall back to the default. There is no next-page marker; a page shorter than per_page is the last one.
Errors
{ "error": { "code": "forbidden", "message": "This token does not have the write scope." } }
| Status | Meaning |
|---|---|
| 401 | Token missing, unknown, revoked or expired, or its user has left the account |
| 402 | The action needs the Pro plan — the account is on Free, or a token issued under Pro outlived it |
| 403 | The token lacks the required scope |
| 404 | No such record in this account, or no such route |
| 422 | The save failed. details names the fields |
| 429 | Rate limit exceeded |
A record in another account returns 404 rather than 403, so that IDs cannot be tested for existence.
Rate limits
1,000 requests per hour per token, across all endpoints. Exceeding it returns 429.
Audit
API writes are recorded in the audit log with the token’s user and the request IP address.
Versioning
- New fields are additive and may appear without notice. Clients should ignore unrecognized keys.
- Breaking changes ship as a new version at
/api/v2, alongside v1. - v1 would then be deprecated on a published timeline. Response shapes are not changed in place.
Not currently available
Webhooks and OAuth apps are planned. Integrations poll the API and authenticate with personal access tokens.
The specification
The OpenAPI specification is generated from the server’s routes and contract-tested against them. Use it to generate a client.