# Getting started

Create a token, find your address, then make your first reads and your first write, one step at a time.

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](https://developers.plnnrly.com/en/docs/api/v2/).

### 1. 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](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens).

Narrow the token to what your integration needs before you ship it. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/).

### 2. Find your base URL

```text
https://<your-url>/api/v2
```

Replace anything in angle brackets. `<your-url>` is the address you sign in to plnnrly on, such as `acme.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/json` or `*/*`. An `Accept` header with neither answers `406`.

### 3. 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 `.mjs` file. The Python uses [httpx](https://www.python-httpx.org/). The JavaScript sets up `base` and `headers` here and the Python sets up `api`; later steps reuse them.

```bash [curl]
curl "https://<your-url>/api/v2" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json"
```

```js [JavaScript]
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)
}
```

```python [Python]
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](https://developers.plnnrly.com/en/docs/api/v2/discovery/) answers one entry per resource your organisation has. Each entry looks like this:

Response 200:

```jsonc
{
  "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 `403` for 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](https://developers.plnnrly.com/en/docs/api/v2/me/get/) is the same kind of call and tells you which user the token acts as.

The answer may be cached for five minutes.

### 4. Read a collection

```bash [curl]
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"
```

```js [JavaScript]
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)
```

```python [Python]
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`, `meta` and `links`. A collection returns each row in its `summary` view.

Response 200:

```jsonc
{
  "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=tags` puts each project's tags inside the row. `tags` is a to-many relation, so `per_page` may be at most 25 while you include it; a larger value answers `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter). Each collection's reference page lists the names it accepts. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/).

Three things in the response to notice before you build on it:

- `links.next` points to the next page. Follow it as it is; never build a cursor yourself. See [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/).
- `RateLimit-Policy` states your allowance. Read it instead of hard-coding a number. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/).
- `ETag` on a page is a weak validator. Send it back as `If-None-Match` and an unchanged page answers `304`. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/).

### 5. Read one record

```bash [curl]
curl "https://<your-url>/api/v2/projects/12" \
  -H "Authorization: Bearer <token>"
```

```js [JavaScript]
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)
```

```python [Python]
response = api.get("/api/v2/projects/12")
etag = response.headers["ETag"]
project = response.json()

print(project["data"]["title"], etag)
```

A single read returns the `detail` view, with every field, without you asking for it.

Response 200:

```jsonc
{
  "data": {
    "id": 12,
    "title": "Portal rebuild",
    "status": "active",
    // every other field of the detail view
  }
}
```

The `ETag` here is a strong validator. Keep it if you plan to change the record: send it back as `If-Match`, and the write is refused with `412` if someone changed the record in the meantime. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/).

### 6. Write something

```bash [curl]
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"
  }'
```

```js [JavaScript]
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 ?? [])
```

```python [Python]
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", []))
```

Response 201:

```jsonc
{
  "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:

1. **Send JSON.** Every body goes with `Content-Type: application/json`. There is no form encoding and no multipart; anything else answers `415`. File contents go through a separate upload step, see [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/).
2. **Request bodies are closed.** An unknown key answers `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) with the code `prohibited` on a pointer to that key. Its message lists the fields you may write.
3. **Send an idempotency key with every POST.** It goes in the `Idempotency-Key` header. Generate it before you send, store it, and reuse it when you retry. [POST /worklogs](https://developers.plnnrly.com/en/docs/api/v2/worklogs/create/) is one of 3 operations that require one, and answers `400` [idempotency-key-required](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-required) without it. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/).
4. **A write answers with the record in its detail view**, so you do not need a follow-up `GET`.
5. **Check for warnings after every write.** A field you may not set can be dropped instead of refused, and `meta.warnings` says so. For example, `is_overtime` without the `times::write_overtime` permission is left out, and the rest of the write is applied. The answer's `meta` then carries:

Response:

```json
{
  "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](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/#permissions-that-change-what-a-call-does).

To change a record, send a `PATCH` with only the fields you change. Add `If-Match` so you cannot overwrite a change you have not seen.

### 7. Handle the failures

Every error response, status `400` and up, is a problem document, as defined in RFC 9457. **Switch on the problem type, never on the status code alone.** A `403`, for example, can be a token problem, a role problem or a rule on the record, and each has a different fix.

Response 403:

```json
{
  "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                                                                                                     |
| ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope) | Create a token that carries one of the abilities in `required_ability`.                                        |
| `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission)   | The user's role lacks the permission in `required_permission`. No token can work around it; a role change can. |
| `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter)   | Read the `errors` array. Each entry names the parameter and the fault.                                         |
| `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed)               | Read the `errors` array. Each entry points to the field and says what is wrong with it.                        |

Log `request_id` with every failure. It is the id we look up in our logs, so quote it when you [contact support](https://www.plnnrly.com/en/support).

Every problem type, with its fix, is in [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/).

## 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](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/log-time/)                                                                        |
| Moving work items through a workflow  | [Move work items through the workflow](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/work-item-workflow/)                                                |
| Learning how the resources relate     | [How the data fits together](https://developers.plnnrly.com/en/docs/api/v2/guides/data-model/)                                                                          |
| Handling dates, durations and amounts | [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/)                                                                                      |
| Exporting a whole collection          | [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/)                                                                                          |
| Narrowing what you fetch              | [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/)                                                                    |
| Fetching less, or more, per row       | [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/)                                                                          |
| Writing while people work in plnnrly  | [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/)                                                                    |
| Attaching files                       | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/)                                                                             |
| Keeping your own copy of the data     | [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/)                                                                            |
| Getting ready for production          | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) and [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) |
| Building an app other people install  | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/)                                                                                  |

Or browse the [reference](https://developers.plnnrly.com/en/docs/api/v2/): 228 operations, each with the token scope and the permission it needs.
