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

Sign in with OAuth

Sign in with OAuth

Let people sign in to your desktop or command-line app with their plnnrly account, instead of pasting a token. OAuth 2.0 with PKCE, step by step.

View as Markdown

An app that other people install can sign each person in with their own plnnrly account. The person approves the app once, and the app gets an access token that it renews itself. Nobody copies a token, and the app never sees a password.

This guide walks through every step and ends with a complete example you can run.

Who this is for

  • Desktop, command-line and native apps that other people install. They receive the sign-in on a loopback address on the person's own computer.
  • Not web apps with their own callback address. An app cannot register an https address of its own; see redirect addresses.
  • Not apps that run in the browser. The token endpoint accepts no cross-origin requests, so a browser app cannot finish the sign-in.
  • Not your own scripts and servers. For those, a personal access token is simpler.

Before you start

  • The organisation has the REST API switched on. It is on unless an administrator switched it off; see For administrators.
  • The person who signs in holds the Manage API tokens permission. In a new organisation the Admin role has it. See who may create tokens.
  • Every address in this guide is on the organisation's own host, https://<your-url>. <your-url> is the address people sign in to plnnrly on, such as acme.plnnrly.com, or the organisation's own domain. Each organisation is separate, so your app asks the person for this address first.

1. Discover the endpoints

Terminal
curl "https://<your-url>/.well-known/oauth-authorization-server"

The answer names everything the rest of this guide uses:

FieldValue
authorization_endpointhttps://<your-url>/oauth/authorize: where you send the person's browser.
token_endpointhttps://<your-url>/oauth/token: where you exchange and refresh tokens.
registration_endpointhttps://<your-url>/oauth/register: where your app registers itself.
revocation_endpointhttps://<your-url>/oauth/revoke: where you sign out.
code_challenge_methods_supported["S256"]
scopes_supportedEvery scope you may ask for.

https://<your-url>/.well-known/oauth-protected-resource/api/v2 describes the API itself and lists the scopes it accepts.

A 404 here means the organisation has the REST API switched off.

2. Register your app

Terminal
curl "https://<your-url>/oauth/register" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme Timesheets",
    "redirect_uris": ["http://127.0.0.1:53682/callback"]
  }'

The answer is 201:

JSON
{
  "client_id": "9e3f2c71-5b8a-4d06-a1f4-7c2e9b5d8a13",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "redirect_uris": ["http://127.0.0.1:53682/callback"],
  "scope": "mcp:use",
  "token_endpoint_auth_method": "none"
}
  • Your app is a public client. It gets no client secret, and token_endpoint_auth_method is none.
  • The client name is what people see. client_name shows on the consent screen and under Connected apps. Use the name they know your app by.
  • Ignore the scope in the answer. Registration always answers with "scope": "mcp:use", whatever your app will ask for. You choose the scopes when you send the person to sign in.
  • A refused redirect address answers 400 with "error": "invalid_redirect_uri".
  • Registration is limited to 5 requests per minute per IP address and 30 per minute per organisation. Register once per host and keep the client_id.

Redirect addresses

After the person approves, their browser goes to your redirect address with a code. Listen on a loopback address while the sign-in runs.

Redirect addressAccepted
http://127.0.0.1:{port}/pathYes. The port may differ at every sign-in, so listen on any free port.
http://[::1]:{port}/pathYes, in the same way.
http://localhost:{port}/pathOnly with exactly the port you registered.
A custom scheme, such as myapp://callbackNo. The only custom scheme accepted is Cursor's own.
An https address of your ownNo. The only https addresses accepted are those of the AI clients plnnrly supports.

On 127.0.0.1 and [::1] everything except the port must match the address you registered. On any other address the whole address must match exactly. A refused address answers invalid_redirect_uri when you register. An address that does not match during sign-in answers invalid_client.

Keep the client_id per host

A client_id works on one organisation's host only. Store it per host, and register separately for each organisation your app connects to.

A registration is deleted overnight once it is more than a day old and nothing issued to it is left. That happens to:

  • an app that never completed a sign-in
  • an app that every person disconnected
  • an app nobody used for a month: after 30 idle days its refresh token stops working, and from about day 32 the registration is gone too

When the token endpoint answers invalid_client, or the browser shows invalid_client during sign-in, register again and start a new sign-in.

3. Send the person to sign in

Create a random code_verifier of 43 to 128 characters and keep it until step 4. The code_challenge is its SHA-256 hash, base64url-encoded without padding. Then open the person's browser at the authorization endpoint with these parameters:

ParameterValue
response_typecode
client_idFrom registration.
redirect_uriThe address you listen on.
scopeThe scopes you need, separated by spaces, such as api:read api:write.
stateA random value. Check that it comes back unchanged.
code_challengeThe hash of your code_verifier.
code_challenge_methodS256

PKCE is required.

Always send the scope parameter. Without scope the sign-in asks for mcp:use, the scope for AI assistants, which cannot call /api/v2. While MCP is switched off, a sign-in without scope answers 404.

Ask for api:read and api:write, or narrow them to a resource, such as api:read:projects and api:write:projects. scopes_supported lists every scope you may ask for, and Scopes and permissions explains what each one covers. There is no destroy class over OAuth: asking for api:destroy fails with invalid_scope.

What the person sees

If the person is not signed in to plnnrly on that host, they sign in first. Then the consent screen shows:

  • the title "Connect Acme Timesheets", and "Acme Timesheets wants to access Acme on your behalf."
  • Signed in as, with their name and email address
  • the scopes you asked for, such as "Read: All resources" or "Write: Projects"
  • "It acts as you: it can see and change what your account can, and nothing more."
  • a warning that the app can also delete records, when you ask for mcp:use and the organisation allows destructive actions
  • the host their browser goes back to, such as "After you approve you are sent back to 127.0.0.1."

The buttons are Approve and Cancel. Approve sends the browser to your redirect address with code and state. Cancel sends it there with error=access_denied.

A person who already approved the same scopes for your app, and still holds a live access token from it, is sent straight back without seeing the screen.

The sign-in is refused when:

  • the person's role lacks Manage API tokens: 403 "Your role does not allow connecting apps to the REST API."
  • the person is in a plnnrly support session: 403
  • you ask for mcp:use while MCP is switched off for the organisation: 404

4. Exchange the code

Terminal
curl "https://<your-url>/oauth/token" \
  -X POST \
  -d grant_type=authorization_code \
  -d client_id=<client_id> \
  -d redirect_uri=http://127.0.0.1:53682/callback \
  -d code_verifier=<code_verifier> \
  -d code=<code>

The token endpoint takes form-encoded bodies. It answers:

JSON
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "<access_token>",
  "refresh_token": "<refresh_token>"
}

Send the access token as the bearer token on /api/v2. It lasts one hour.

The token endpoint allows 120 requests per minute per organisation and IP address, enough for many apps behind one office connection. The discovery documents and the revocation endpoint allow 30 requests per minute per organisation and IP address.

5. Refresh

Once the access token has expired, /api/v2 answers 401 unauthenticated. Trade the refresh token for a new pair:

Terminal
curl "https://<your-url>/oauth/token" \
  -X POST \
  -d grant_type=refresh_token \
  -d client_id=<client_id> \
  -d refresh_token=<refresh_token>
  • Each refresh token works once. The answer carries a new one, and the old one stops working, so store the new one straight away.
  • A refresh token lasts 30 days, counted from the last refresh. An app used at least once every 30 days stays signed in.
  • After 30 idle days the refresh answers invalid_grant, and the person signs in again.

6. Sign out

Terminal
curl "https://<your-url>/oauth/revoke" \
  -X POST \
  -d token=<refresh_token> \
  -d token_type_hint=refresh_token \
  -d client_id=<client_id>

Revoking either token disconnects the app for that person: every token it holds for them stops working. The endpoint answers 200 even for a token it does not recognise, as RFC 7009 requires.

People can also disconnect an app themselves: they open API access in the user menu and choose Revoke next to the app under Connected apps. See connected apps.

What an OAuth app cannot do

  • Delete anything. Every DELETE needs the destroy class, which OAuth never offers. Deleting needs a personal access token with Delete.
  • Upload file contents. The upload step accepts personal access tokens only, so an OAuth token gets 401 there. See Files: upload and download.
  • Reach more over MCP. If the same sign-in also asks for mcp:use, the app is held to its api: scopes there too. See MCP access.

The person's Manage API tokens permission is checked on every request. If an administrator takes it away, the app's calls answer 403 insufficient-permission until the permission is given back.

A complete example

This script signs a person in and prints their name. It opens a loopback listener on a free port, registers, creates the PKCE pair, opens the browser, exchanges the code and calls GET/me.

Save the JavaScript as sign-in.mjs and run it with Node 20 or later, with no packages to install. Save the Python as sign_in.py and run it with Python 3.10 or later after pip install httpx. Pass the address to sign in to:

Terminal
node sign-in.mjs acme.plnnrly.com
python sign_in.py acme.plnnrly.com
import { spawn } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'

const host = process.argv[2]

if (!host) {
  console.error('Usage: node sign-in.mjs <your-url>')
  process.exit(1)
}

const base = `https://${host}`
const scope = 'api:read'

async function call(url, init = {}) {
  const response = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init.headers } })
  const text = await response.text()

  if (!response.ok) {
    throw new Error(`${response.status} from ${url}: ${text}`)
  }

  return JSON.parse(text)
}

function openBrowser(url) {
  const [command, args] = {
    darwin: ['open', [url]],
    win32: ['rundll32', ['url.dll,FileProtocolHandler', url]],
  }[process.platform] ?? ['xdg-open', [url]]

  spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
}

// 1. Listen on a free port on the loopback address.
const server = createServer()
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`

// 2. Discover the endpoints.
const metadata = await call(`${base}/.well-known/oauth-authorization-server`)

// 3. Register. A real app stores client_id per host and registers again on invalid_client.
const { client_id: clientId } = await call(metadata.registration_endpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ client_name: 'Acme Timesheets', redirect_uris: [redirectUri] }),
})

// 4. Create the PKCE pair and send the person to sign in.
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier).digest('base64url')
const state = randomBytes(16).toString('base64url')

const authorizeUrl = new URL(metadata.authorization_endpoint)
authorizeUrl.search = new URLSearchParams({
  response_type: 'code',
  client_id: clientId,
  redirect_uri: redirectUri,
  scope,
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
})

const callback = new Promise((resolve) => {
  server.on('request', (request, response) => {
    const url = new URL(request.url, redirectUri)

    if (url.pathname !== '/callback') {
      response.writeHead(404).end()
      return
    }

    response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
    response.end('You can close this window and go back to the app.')
    resolve(url.searchParams)
  })
})

console.log(`Opening your browser. If it does not open, visit:\n${authorizeUrl}`)
openBrowser(authorizeUrl.href)

const params = await callback
server.close()

if (params.get('state') !== state) {
  throw new Error('The state does not match. Start the sign-in again.')
}

if (params.has('error')) {
  throw new Error(`The sign-in was refused: ${params.get('error')}`)
}

// 5. Exchange the code for tokens.
const tokens = await call(metadata.token_endpoint, {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: clientId,
    redirect_uri: redirectUri,
    code_verifier: verifier,
    code: params.get('code'),
  }),
})

// 6. Call the API as the person who signed in.
const me = await call(`${base}/api/v2/me`, {
  headers: { Authorization: `Bearer ${tokens.access_token}` },
})

console.log(`Signed in as ${me.data.name}`)
import base64
import hashlib
import secrets
import sys
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse

import httpx

if len(sys.argv) != 2:
    sys.exit("Usage: python sign_in.py <your-url>")

base = f"https://{sys.argv[1]}"
scope = "api:read"


class Callback(BaseHTTPRequestHandler):
    params = None
    timeout = 5

    def do_GET(self):
        url = urlparse(self.path)

        if url.path != "/callback":
            self.send_response(404)
            self.end_headers()
            return

        Callback.params = {key: values[0] for key, values in parse_qs(url.query).items()}
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()
        self.wfile.write(b"You can close this window and go back to the app.")

    def log_message(self, *args):
        pass


def call(response):
    if response.is_error:
        sys.exit(f"{response.status_code} from {response.url}: {response.text}")

    return response.json()


# 1. Listen on a free port on the loopback address.
server = HTTPServer(("127.0.0.1", 0), Callback)
redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"

with httpx.Client(headers={"Accept": "application/json"}) as client:
    # 2. Discover the endpoints.
    metadata = call(client.get(f"{base}/.well-known/oauth-authorization-server"))

    # 3. Register. A real app stores client_id per host and registers again on invalid_client.
    client_id = call(client.post(
        metadata["registration_endpoint"],
        json={"client_name": "Acme Timesheets", "redirect_uris": [redirect_uri]},
    ))["client_id"]

    # 4. Create the PKCE pair and send the person to sign in.
    verifier = secrets.token_urlsafe(32)
    digest = hashlib.sha256(verifier.encode()).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
    state = secrets.token_urlsafe(16)

    authorize_url = metadata["authorization_endpoint"] + "?" + urlencode({
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": scope,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })

    print(f"Opening your browser. If it does not open, visit:\n{authorize_url}")
    webbrowser.open(authorize_url)

    while Callback.params is None:
        server.handle_request()
    server.server_close()

    params = Callback.params

    if params.get("state") != state:
        sys.exit("The state does not match. Start the sign-in again.")

    if "error" in params:
        sys.exit(f"The sign-in was refused: {params['error']}")

    # 5. Exchange the code for tokens.
    tokens = call(client.post(metadata["token_endpoint"], data={
        "grant_type": "authorization_code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "code_verifier": verifier,
        "code": params["code"],
    }))

    # 6. Call the API as the person who signed in.
    me = call(client.get(
        f"{base}/api/v2/me",
        headers={"Authorization": f"Bearer {tokens['access_token']}"},
    ))

print(f"Signed in as {me['data']['name']}")

A real app does three things this script leaves out. It stores the client_id per host instead of registering on every run. It keeps the refresh token in the system's credential store and refreshes when a call answers 401. And it asks for only the scopes it needs.