Deze referentie is alleen in het Engels. Naar het Nederlandse REST API-overzicht

Diese Referenz gibt es nur auf Englisch. Zur deutschen REST-API-Übersicht

Cette référence existe uniquement en anglais. Aller à la présentation de l'API REST en français

Esta referencia solo está en inglés. Ir a la introducción de la API REST en español

Getting started

Getting started

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

View as Markdown

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.

  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.

    Narrow the token to what your integration needs before you ship it. See API tokens.

  2. Find your base URL

    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. The JavaScript sets up base and headers here and the Python sets up api; 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 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 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

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

    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=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. 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.next points to the next page. Follow it as it is; never build a cursor yourself. See Pagination.
    • RateLimit-Policy states your allowance. Read it instead of hard-coding a number. See 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.
  5. 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 detail view, 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 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.

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

    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.
    2. Request bodies are closed. An unknown key answers 422 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 is one of 3 operations that require one, and answers 400 idempotency-key-required without it. See 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:
    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 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.

    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:

    ProblemWhat to do
    403 insufficient-token-scopeCreate a token that carries one of the abilities in required_ability.
    403 insufficient-permissionThe user's role lacks the permission in required_permission. No token can work around it; a role change can.
    400 invalid-query-parameterRead the errors array. Each entry names the parameter and the fault.
    422 validation-failedRead 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.

    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.

If you areRead
Logging time from a scriptLog time from a script
Moving work items through a workflowMove work items through the workflow
Learning how the resources relateHow the data fits together
Handling dates, durations and amountsData formats
Exporting a whole collectionPagination
Narrowing what you fetchFiltering and sorting
Fetching less, or more, per rowViews and includes
Writing while people work in plnnrlyConcurrency and ETags
Attaching filesFiles: upload and download
Keeping your own copy of the dataKeeping a copy in sync
Getting ready for productionRate limits and Errors and retries
Building an app other people installSign in with OAuth

Or browse the reference: 228 operations, each with the token scope and the permission it needs.