REST API
Base address: https://work.nod3.se/api/v1. JSON in and out.
AI assistants (Claude, ChatGPT) use the MCP server at /api/mcp with OAuth instead of API keys: see MCP.md.
- Created under Settings → Integrations → REST API by people with the
integrationspermission. Shown once; only a SHA-256 hash is stored (api_tokens). Keys start withw3_. - Sent as
Authorization: Bearer w3_…. The session cookie is never accepted here, so/api/v1/*is exempt from the same-origin check. - A key belongs to the workspace, not a person. It acts through its own service user (“API ·
”) with a role picked when it is made (an admin- or member-based role, never more than its maker may hand out). RLS and permissions apply exactly as for a member with that role, and what it does shows under its name in comments, activity and the audit log. It is not a member: it never shows in member lists, pickers or seat counts. Migration 0034_api_key_roles.sql; the role functions fall back toapp.current_api_token(). - Optional limits: only some projects (every endpoint then sees only those) and an end date (30, 90 or 365 days, default 90, or never).
readkeys may onlyGET; anything else answers403 read_only_key.- A key stops working when revoked, when it expires, or when the workspace is suspended or deleted; it keeps working when people leave. Revoked keys stay in the table so their name still shows on old work.
- Personal keys (
api_tokens.personal, 0061, docs/PERMISSIONS.md): made by a person under My integrations when the workspace’sapi_keysrule allows them (off by default). They act as that person (their user, role and RLS), always end (30/90/365 days), at most 10 per person, and stop working when the rule turns off or the person leaves.GET /meshowspersonal: true. - 120 requests per minute per key (
429 rate_limited,Retry-After: 60). Creating and revoking keys is in the audit log. - JSON bodies up to 1 MB, uploads up to the file size limit; larger requests get
413 too_large(orfile_too_large) before anything is read further.
Endpoints
Section titled “Endpoints”| Method and path | What it does |
|---|---|
GET /me |
The workspace, and the key: name, whether it is personal, role, permissions and scope |
GET /workflow |
Statuses (with category), types, priorities and labels, with ids |
GET /members |
People in the workspace: id, name, email (only the part before @ without the members permission), role |
GET /projects |
All projects the key can see, each with its labels (tag names) |
POST /projects |
Create: name, optional key, description, leadId (none if left out), client, valueAmount, valueCurrency, contactName, contactEmail, startDate, dueDate, template, labels (names). Not for project-limited keys (403 project_limited_key) |
GET /projects/{key} |
One project |
PATCH /projects/{key} |
Change any create field except key and template, plus archived; labels replaces the tags |
POST /projects/{key}/messages |
Post in the project’s chat: body. The key need not be a member and can never read the chat |
POST /projects/{key}/files |
Upload a file to the project: multipart/form-data with a file field. Same types, size limit and quota as the app |
GET /items |
Items, oldest first. Query: project (key), status, assignee (ids), updatedSince (ISO time), q (every word in the title or description, or a reference such as WEB-12), archived (false by default: live items only; true: only archived; all), limit (1–100, default 50), cursor |
POST /items |
Create: project (key), title, optional description, typeId, statusId, priorityId, assigneeId, parentRef, dueDate (YYYY-MM-DD), labels (names) |
GET /items/{ref} |
One item, such as WEB-12 |
PATCH /items/{ref} |
Change any of the create fields except project; force: true closes an item that still waits on others where the workspace only warns; archived: true archives the item and its sub-items, false restores what was archived with it. There is no delete: the API archives |
GET /items/{ref}/comments |
Comments, oldest first |
POST /items/{ref}/comments |
Add a comment: body |
POST /items/{ref}/files |
Upload a file to the item, as for projects |
GET /webhooks |
The webhooks this key registered |
POST /webhooks |
Register a webhook for this key: url, events, optional description and dueSoonHour (0–23, default 8: when item.due_soon goes). Answers id and the secret (once). Needs a key whose role has integrations and no project limit |
DELETE /webhooks/{id} |
Remove one of this key’s webhooks (204) |
Lists answer { "data": [...], "nextCursor": "..." | null }; pass nextCursor as cursor for the next page. An item:
{ "id": "…", "ref": "WEB-12", "project": "WEB", "projectName": "Website", "title": "…", "description": "…", "typeId": "…", "type": "Task", "statusId": "…", "status": "In progress", "statusCategory": "in_progress", "priorityId": null, "priority": null, "assigneeId": "…", "assignee": "Anna Svensson", "parentRef": null, "dueDate": "2026-11-30", "labels": ["Design"], "archived": false, "url": "https://work.nod3.se/w/nod3/items/WEB-12", "createdAt": "…", "updatedAt": "…"}The names (projectName, type, status, priority, assignee) are for reading and filtering; send the ids when you write. assignee is the person’s name, or the first part of their email when they have none.
A project:
{ "id": "…", "key": "WEB", "name": "Website", "description": "…", "client": "Acme AB", "clientUrl": "https://app.hubspot.com/contacts/…/record/0-2/…", "valueAmount": 120000, "valueCurrency": "SEK", "contactName": "Anna Svensson", "contactEmail": "anna@acme.se", "startDate": "2026-11-01", "dueDate": "2026-12-15", "leadId": "…", "lead": "Anna Svensson", "archived": false, "createdAt": "…"}clientUrl, the value, contact and start date come from the deal when a CRM rule made the project, or are set in Edit project; each may be null.
Writes go through the same functions as the app, so events, automation rules, notifications and the audit log behave the same.
Errors
Section titled “Errors”{ "error": "<code>" } with the HTTP status: 401 unauthorized, 403 read_only_key | forbidden, 404 not_found | item_not_found | project_not_found, 400 invalid_input (with issues), invalid_json, invalid_cursor, 409 for workflow conflicts (for example item_blocked), 413 too_large, 429 rate_limited.
402 when the workspace’s plan does not allow it: plan_limit_<key> (for example plan_limit_projects, plan_limit_members, plan_limit_storage_gb) or plan_feature_<feature> (for example plan_feature_api). Nothing was changed; an owner or admin can change plan under Settings → Plan and billing. See BILLING.md.
Outgoing webhooks
Section titled “Outgoing webhooks”Settings → Integrations → Webhooks.
- Every body has
actorNamenext toactorId(who did it; an API key shows as “API ·”). - In
item.updated, changes tostatusId,typeId,priorityId,assigneeIdandparentId(its reference) also carryfromNameandtoName. project.updatedcarrieschangeswith only the fields whose stored value changed, as their new values (labelsas names,archivedas true/false); the edit form sends every field, soupdateProjectcompares before and after, and sends no event when nothing changed.previoushas the same fields with their old values; a changedleadUserIdalso giveslead(the name) on both sides.item.updatedandproject.updatedalso carrydiff: the same changes per field under plain names (status,type,priority,assignee,parent,lead,title, …), each as{ from, into }where a value with an ID is{ id, name }and a side without a value is left out. Make shows it as Changes.- Changing an item’s tags is an
item.updatedwithchanges.labels: { from, to }(tag names). Tags given when an item is created are part ofitem.created. - Archiving or restoring an item is an
item.updatedwithchanges.archived: { from, to }, one per item including each sub-item archived with it.item.due_soonskips archived items. - An endpoint is a public
https://address plus the events it wants:item.created,item.updated,item.deleted,comment.created,project.created,project.updated,project.deleted, anditem.due_soon. item.due_sooncomes once per item and due date, the day before an open item is due in the workspace’s time zone (app.queue_due_soon_events, run with the due reminders, 0047). It is only made when some endpoint asks for it, or an active workflow listens for it (0086). Each endpoint gets it at itsdue_soon_hour(default 08:00, migration 0072) in the workspace’s time zone, or at once if that hour has passed; if the item is done, archived or moved by then, the delivery is dropped unsent (deliverWebhooks).- Automation apps (n8n, Make, Zapier) register their own endpoints through
POST /webhookswhen a flow is turned on. Those belong to the key (webhook_endpoints.api_token_id): only that key lists or deletes them, they show under Webhooks in the app too, and they stop sending when the key is revoked or expires (revoking also deletes them). Apps:integrations/in the repo, see AUTOMATION.md. - The worker turns each outbox event into one delivery per subscribed endpoint (
webhook_deliveries), with the body built once, so retries send the same bytes. Then it POSTs JSON:
{ "id": "evt_123", "type": "item.updated", "createdAt": "…", "workspace": { "id": "…", "slug": "nod3" }, "actorId": "…", "data": { "item": { …same shape as the API… }, "changes": { "statusId": { "from": "…", "to": "…", "fromName": "To-do", "toName": "Done" } } } }- Headers:
WORK3-Event,WORK3-Delivery(id, for de-duplication),WORK3-Signature: t=<unix seconds>,v1=<hex>wherev1 = HMAC-SHA256(secret, "<t>.<raw body>"). Receivers should compare in constant time and reject atolder than 5 minutes. - The secret (
whsec_…) is shown once, stored encrypted withENCRYPTION_KEY, and can be rotated. - A 2xx within 10 seconds counts as delivered. Otherwise it is tried again after 1 min, 5 min, 30 min, 2 h and 6 h, then marked failed; admins can send a failed one again. Deliveries are kept 30 days.
- Safety: https only, no redirects, and private, loopback, link-local and other internal ranges are refused at connect time after DNS (so a hostname cannot point inward), and when the address is saved.
- “Send test” queues a
webhook.testevent for one endpoint. - Loops (a webhook into n8n that calls the API, which fires the webhook again) are the receiver’s to avoid: check
actorIdagainst the key’s service user.
WORK³ · powered by NOD³