This guide takes you from no token to a first write in seven steps. Every request below is a real operation with its own page in the reference.
Create a token
In plnnrly, open API access in the user menu (top right, under your name), choose Create token and fill in the form:
- Name: what the token is for, such as "First steps".
- Scopes: tick Read and Write. Ticking Write ticks Read for you.
- Resources: leave it on All resources for now.
- Expires: keep the default.
Copy the token from the dialog that opens. It is shown once.
No API access in your user menu? Either the REST API is switched off for your organisation, or your role lacks the Manage API tokens permission. See who may create tokens.
Narrow the token to what your integration needs before you ship it. See API tokens.
Find your base URL
https://<your-url>/api/v2Replace anything in angle brackets.
<your-url>is the address you sign in to plnnrly on, such asacme.plnnrly.com, or your organisation's own domain.<token>is the token you created in step 1. The API tokens page shows this address ready to copy, under How to connect.- Use that exact host. If your organisation has its own domain, only that domain works. The API never redirects, so another host answers
404. - Send the token on every request, in the header
Authorization: Bearer <token>. - The Accept header is optional. Leave it out, or make sure it includes
application/jsonor*/*. AnAcceptheader with neither answers406.
- Use that exact host. If your organisation has its own domain, only that domain works. The API never redirects, so another host answers
See what your organisation exposes
Each sample comes in three forms: curl, JavaScript and Python. The JavaScript runs on Node 20 or later as an ES module, so save it as a
.mjsfile. The Python uses httpx. The JavaScript sets upbaseandheadershere and the Python sets upapi; later steps reuse them.curl "https://<your-url>/api/v2" \ -H "Authorization: Bearer <token>" \ -H "Accept: application/json"const base = 'https://<your-url>/api/v2' const headers = { Authorization: 'Bearer <token>' } const discovery = await fetch(base, { headers }).then((response) => response.json()) for (const resource of discovery.data) { console.log(resource.name, resource.path) }import httpx api = httpx.Client( base_url="https://<your-url>", headers={"Authorization": "Bearer <token>"}, ) discovery = api.get("/api/v2").json() for resource in discovery["data"]: print(resource["name"], resource["path"])Discovery answers one entry per resource your organisation has. Each entry looks like this:
Response200{ "data": [ { "name": "Projects", "path": "https://acme.plnnrly.com/api/v2/projects", "item_path": "https://acme.plnnrly.com/api/v2/projects/{project}", "permission_prefix": "projects", "summary_schema": "#/components/schemas/ProjectList", "detail_schema": "#/components/schemas/Project", "operations": ["index", "store", "show", "update", "destroy", "actions.restore"] } // one entry for every other resource, sorted by name ] // then meta and links, as on every collection }The list only leaves out resources of features your organisation does not have, such as billing. It does not look at your token or your role, so a resource listed here can still answer
403for you.Discovery needs a valid token and no permission, which makes it a good first call. If it answers
200, your token and your address are right. GET/me is the same kind of call and tells you which user the token acts as.The answer may be cached for five minutes.
Read a collection
curl -G "https://<your-url>/api/v2/projects" \ -H "Authorization: Bearer <token>" \ --data-urlencode "per_page=25" \ --data-urlencode "filter[status]=active" \ --data-urlencode "sort=-last_activity" \ --data-urlencode "include=tags"const query = new URLSearchParams({ per_page: '25', 'filter[status]': 'active', sort: '-last_activity', include: 'tags', }) const page = await fetch(`${base}/projects?${query}`, { headers }).then((response) => response.json()) console.log(page.data.length, page.links.next)page = api.get( "/api/v2/projects", params={ "per_page": 25, "filter[status]": "active", "sort": "-last_activity", "include": "tags", }, ).json() print(len(page["data"]), page["links"]["next"])You get the standard envelope:
data,metaandlinks. A collection returns each row in itssummaryview.Response200{ "data": [ // up to 25 active projects in the summary view, each with its tags ], "meta": { "page": { "per_page": 25, "next_cursor": "<opaque cursor>", "prev_cursor": null, "total": 41, "current_page": null, "last_page": null } }, "links": { // self, next and prev: ready-made URLs that repeat your query } }include=tagsputs each project's tags inside the row.tagsis a to-many relation, soper_pagemay be at most 25 while you include it; a larger value answers400invalid-query-parameter. Each collection's reference page lists the names it accepts. See Views and includes.Three things in the response to notice before you build on it:
links.nextpoints to the next page. Follow it as it is; never build a cursor yourself. See Pagination.RateLimit-Policystates your allowance. Read it instead of hard-coding a number. See Rate limits.ETagon a page is a weak validator. Send it back asIf-None-Matchand an unchanged page answers304. See Concurrency and ETags.
Read one record
curl "https://<your-url>/api/v2/projects/12" \ -H "Authorization: Bearer <token>"const response = await fetch(`${base}/projects/12`, { headers }) const etag = response.headers.get('ETag') const project = await response.json() console.log(project.data.title, etag)response = api.get("/api/v2/projects/12") etag = response.headers["ETag"] project = response.json() print(project["data"]["title"], etag)A single read returns the
detailview, with every field, without you asking for it.Response200{ "data": { "id": 12, "title": "Portal rebuild", "status": "active", // every other field of the detail view } }The
ETaghere is a strong validator. Keep it if you plan to change the record: send it back asIf-Match, and the write is refused with412if someone changed the record in the meantime. See Concurrency and ETags.Write something
curl "https://<your-url>/api/v2/worklogs" \ -X POST \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c0a52-3e8b-4b7a-9d55-0c1e2f3a4b5c" \ -d '{ "project_id": 12, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding" }'const idempotencyKey = crypto.randomUUID() const written = await fetch(`${base}/worklogs`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ project_id: 12, date: '2026-10-14', minutes: 150, description: 'Reviewed the invoice export and fixed the rounding', }), }).then((response) => response.json()) console.log(written.data.id, written.meta?.warnings ?? [])import uuid idempotency_key = str(uuid.uuid4()) written = api.post( "/api/v2/worklogs", headers={"Idempotency-Key": idempotency_key}, json={ "project_id": 12, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding", }, ).json() print(written["data"]["id"], written.get("meta", {}).get("warnings", []))Response201{ "data": { "id": 5120, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding", "project_id": 12, // every other field of the detail view } }Five rules apply to every write in this API:
- Send JSON. Every body goes with
Content-Type: application/json. There is no form encoding and no multipart; anything else answers415. File contents go through a separate upload step, see Files: upload and download. - Request bodies are closed. An unknown key answers
422validation-failed with the codeprohibitedon a pointer to that key. Its message lists the fields you may write. - Send an idempotency key with every POST. It goes in the
Idempotency-Keyheader. Generate it before you send, store it, and reuse it when you retry. POST/worklogs is one of 3 operations that require one, and answers400idempotency-key-required without it. See Idempotency. - A write answers with the record in its detail view, so you do not need a follow-up
GET. - Check for warnings after every write. A field you may not set can be dropped instead of refused, and
meta.warningssays so. For example,is_overtimewithout thetimes::write_overtimepermission is left out, and the rest of the write is applied. The answer'smetathen carries:
When a field is ignored{ "warnings": [ { "code": "input_ignored", "detail": "The field \"is_overtime\" was ignored: writing it requires the times::write_overtime permission. The rest of the write was applied.", "pointer": "/is_overtime" } ] }See permissions that change what a call does.
To change a record, send a
PATCHwith only the fields you change. AddIf-Matchso you cannot overwrite a change you have not seen.- Send JSON. Every body goes with
Handle the failures
Every error response, status
400and up, is a problem document, as defined in RFC 9457. Switch on the problem type, never on the status code alone. A403, for example, can be a token problem, a role problem or a rule on the record, and each has a different fix.Response403{ "type": "https://developers.plnnrly.com/api/v2/problems/insufficient-token-scope", "title": "Insufficient token scope", "status": 403, "detail": "This token does not carry an ability that covers this endpoint.", "instance": "/api/v2/drive-documents/91", "request_id": "01K7F3Q9YV3PZ2N8T1E6M0XR4S", "required_ability": [ "api:read:project_drive", "api:read:customer_drive", "api:read:organization_drive", "api:read:user_drive" ] }The ones you will meet first while building:
Problem What to do 403insufficient-token-scopeCreate a token that carries one of the abilities in required_ability.403insufficient-permissionThe user's role lacks the permission in required_permission. No token can work around it; a role change can.400invalid-query-parameterRead the errorsarray. Each entry names the parameter and the fault.422validation-failedRead the errorsarray. Each entry points to the field and says what is wrong with it.Log
request_idwith every failure. It is the id we look up in our logs, so quote it when you contact support.Every problem type, with its fix, is in Errors and retries.
Step 1 of 7: Create a token
Nothing to send in this step.
Try it safely
There is no sandbox organisation, so every write changes real data.
- Start with a token that has only Read.
- When you start writing, create a Write token narrowed to the resources you need, and try your writes on a test project.
- Keep Delete out of every token that does not need it.
A project with logged time cannot be deleted. To remove a test project afterwards, delete its test worklogs first.
What to read next
| If you are | Read |
|---|---|
| Logging time from a script | Log time from a script |
| Moving work items through a workflow | Move work items through the workflow |
| Learning how the resources relate | How the data fits together |
| Handling dates, durations and amounts | Data formats |
| Exporting a whole collection | Pagination |
| Narrowing what you fetch | Filtering and sorting |
| Fetching less, or more, per row | Views and includes |
| Writing while people work in plnnrly | Concurrency and ETags |
| Attaching files | Files: upload and download |
| Keeping your own copy of the data | Keeping a copy in sync |
| Getting ready for production | Rate limits and Errors and retries |
| Building an app other people install | Sign in with OAuth |
Or browse the reference: 228 operations, each with the token scope and the permission it needs.