Skip to content
Connect an agent

SDKs

Sign in with Plotwork

How to let the people of your app sign in with their Plotwork account and call the Plotwork API as themselves, with no API key pasted anywhere. It is OAuth 2.1 with PKCE, for developers who build a company portal, a product that inserts or publishes Plotwork diagrams, or a browser app with no server.

TL;DR

QuestionAnswer
Who is it for?An app whose own users have Plotwork accounts: a developer portal such as Backstage that shows each engineer their organisation's private library, a product that lets its users insert or publish diagrams, or a browser app.
When do I use something else?A public website whose visitors have no Plotwork account shows diagrams with a stable link or an embed link. CI and scripts use an API key or a trusted publisher.
How do I start?Register the app in the portal under Developers, Developer apps (or an organisation's Developers, Apps). You get a client id, and a client secret for an app with a server.
What can an app ask for?diagrams:use, diagrams:publish, library:read, profile and email. The person sees each one in plain words and approves once.
How long do tokens last?An access token an hour; a refresh token 30 days, used once, and replaced at each refresh. A refresh token used twice ends the connection.
Is there a library?Yes: @plotwork.official/sdk/oauth for TypeScript and plotwork.oauth for Python, and the viewer shows private diagrams with the person's token. See SDKs.
Which plan?Every plan may register and approve apps. An app's calls spend the plan of the person or organisation that approved it.
Can an organisation control it?Its owner and admins see and end every app connected to it. On Enterprise, "Only approved apps can connect" keeps it to a list.

When to use it

You want toUse
Show a person their own diagrams, private ones included, in your appSign in with Plotwork with library:read
Let a person draw or publish from your product, on their own account and planSign in with Plotwork with diagrams:use or diagrams:publish
Show a published diagram to anyone on a public pageA stable link or an embed link, no sign-in
Draw diagrams from CI, a script or a server jobAn API key, or a trusted publisher in CI

A token from Sign in with Plotwork belongs to one person and acts only within what they approved. An API key belongs to whoever made it and must stay on a server.

Register an app

Open the portal (opens in a new tab) and choose Developers, Developer apps, for an app of your own. For an app that belongs to an organisation, its owner or an admin chooses the organisation's Developers, Apps; such an app can be limited to the organisation's members.

FieldRule
Name3 to 60 characters. People see it on the consent page as a claim, beside where the app sends them back.
HomepageOptional, https.
Redirect addressesOne to ten. https, or http on localhost, 127.0.0.1 or [::1] for development and desktop tools. No fragment, no user name or password, at most 2048 characters. A sign-in must name one exactly, port and path included.
TypeConfidential, for an app with a server: a secret (pws_...) is shown once, and you can make a new one. Public, for a browser or desktop app with no server: no secret. The type cannot change later.
Members onlyFor an organisation's app: only its members can connect it, and only to that organisation.

An account or an organisation can register 25 apps. The portal shows the client id with a Copy button, the authorize, token and metadata addresses, and the scopes.

The flow

StepWhat happens
1. AuthorizeYour app makes a PKCE verifier and its S256 challenge and a random state, keeps them, and sends the browser to https://api.plotwork.dev/oauth/authorize.
2. ApprovePlotwork checks the app and the address, and shows the consent page. A person who is signed out signs in first. They see the app's name, where it sends them back, each scope, and "Connect as": their own account or an organisation.
3. CodeThe browser comes back to your redirect address with code, state and iss. The code works once, for 60 seconds.
4. ExchangeYour app checks state and iss, then posts the code and the verifier to /oauth/token and gets an access token and a refresh token.
5. CallEvery /v1 call sends Authorization: Bearer pwa_....
6. RefreshBefore the hour is up, your app trades the refresh token for a new pair.

A sign-in link is good for ten minutes and is answered once. A person who opens it again is told it was used, and starts again from your app.

Scopes

ScopeLets the appRoutes
diagrams:useValidate, draw, render and import: computation on what the app sendsEvery /v1 route that computes
diagrams:publishPublish a diagram on the account or organisationPOST /v1/diagrams/publish
library:readList and show the diagrams the account can see, private ones included, under the organisation's folder rulesGET /v1/library, GET /v1/library/{id}.svg
profileRead the account's id and nameGET /v1/me
emailAdd the email address to GET /v1/me (with profile)GET /v1/me

Ask only for what your feature needs. A route outside the approved scopes answers 403 oauth.insufficient_scope with the scope in details.scope. A token never opens the live whiteboards, the portal's own routes, or an organisation's settings.

Tokens

TokenLooks likeLastsNotes
Access tokenpwa_ and 64 hex charactersOne hour (expires_in is 3600)Send it as Authorization: Bearer.
Refresh tokenpwr_ and 64 hex characters30 days, onceEach refresh answers a new one with a fresh 30 days, and the old one stops working.
Codepwc_ and 64 hex characters60 seconds, onceBound to your client, the redirect address and the PKCE challenge.

A refresh token or a code that is used a second time ends the whole connection, because only a stolen copy is used twice. The person then has to approve the app again. Plotwork stores every token, code and secret only as a SHA-256 hash, so ending a connection ends its tokens at their next call.

Approving the same app again for the same account replaces the earlier connection.

Endpoints

The issuer is https://api.plotwork.dev. The metadata at /.well-known/oauth-authorization-server lists the endpoints, PKCE S256 only, the client authentication methods (client_secret_basic, client_secret_post and none), the five scopes, and authorization_response_iss_parameter_supported.

Authorize

GET /oauth/authorize, in the browser.

ParameterValue
response_typecode
client_idYour app's client id
redirect_uriOne of the app's registered addresses, exactly. May be left out when the app has one.
scopeOne or more scopes, separated by spaces
stateA random value you keep and check, at most 1024 characters
code_challengeThe base64url SHA-256 of the verifier, 43 characters
code_challenge_methodS256

An unknown client_id is 400 oauth.invalid_client, and an address the app did not register 400 oauth.invalid_redirect_uri, shown on the API host and never sent to the address. Every other mistake goes back to your address with error (unsupported_response_type, invalid_request or invalid_scope), error_description, state and iss. A person who says no comes back with error=access_denied.

Token

POST /oauth/token, form-encoded, from your server or the browser app.

GrantFields
authorization_codegrant_type, code, redirect_uri (the same one), code_verifier, client_id
refresh_tokengrant_type, refresh_token, client_id, and optional scope naming only scopes the connection holds

A confidential app sends its secret by HTTP Basic or as client_secret in the form, once. The answer is access_token, token_type (Bearer), expires_in, refresh_token and scope, with cache-control: no-store. A refusal is OAuth's own shape:

json
{  "error": "invalid_grant",  "error_description": "The code is not valid, has expired, or was already used."}

invalid_client is 401; invalid_request, invalid_grant, invalid_scope and unsupported_grant_type are 400. The endpoint answers any origin, without credentials, and is rate-limited by address.

Revoke

POST /oauth/revoke, form-encoded, with token and the same client authentication. An access token ends; a refresh token ends the whole connection, which is how an app signs a person out. An unknown token is answered 200 as well.

Calls

GET /v1/me answers who the token acts as. GET /v1/library answers a page of diagrams (limit, cursor, folder), and GET /v1/library/{id}.svg one picture with an ETag. Every other /v1 operation is in the API reference.

An app with a server

A confidential app keeps the secret and the tokens on its server; the browser never sees a token. With the TypeScript SDK, as Next.js route handlers:

ts
// app/auth/plotwork/route.ts: send the person to Plotwork.import { authorizeUrl, createPkce } from '@plotwork.official/sdk/oauth'import { cookies } from 'next/headers'import { redirect } from 'next/navigation'
const clientId = process.env.PLOTWORK_CLIENT_ID ?? ''const redirectUri = 'https://example.com/auth/plotwork/callback'
export async function GET() {  const pkce = await createPkce()  const { url, state } = authorizeUrl({ clientId, redirectUri, scopes: ['library:read', 'profile'], codeChallenge: pkce.challenge })  const jar = await cookies()  jar.set('plotwork_sign_in', JSON.stringify({ state, verifier: pkce.verifier }), { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600 })  redirect(url)}
ts
// app/auth/plotwork/callback/route.ts: trade the code for tokens.import { exchangeCode, parseRedirect } from '@plotwork.official/sdk/oauth'import { cookies } from 'next/headers'import { redirect } from 'next/navigation'
const clientId = process.env.PLOTWORK_CLIENT_ID ?? ''const clientSecret = process.env.PLOTWORK_CLIENT_SECRETconst redirectUri = 'https://example.com/auth/plotwork/callback'
export async function GET(request: Request) {  const jar = await cookies()  const { state, verifier } = JSON.parse(jar.get('plotwork_sign_in')?.value ?? '{}')  jar.delete('plotwork_sign_in')  const code = parseRedirect(request.url, state) // throws on a foreign state, another iss, or error=  const tokens = await exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier: verifier })  await saveToSession(tokens) // your server-side session store  redirect('/')}

Then, for each request:

ts
import { Plotwork } from '@plotwork.official/sdk'import { withRefresh } from '@plotwork.official/sdk/oauth'
const plotwork = new Plotwork({  accessToken: withRefresh(session.tokens, { clientId, clientSecret, onRefresh: (tokens) => saveToSession(tokens) }),})const { items } = await plotwork.library.list()

withRefresh refreshes a minute before the access token ends, once however many calls ask, and hands each new pair to onRefresh to save. Use one per connection. Every refusal throws PlotworkOAuthError with OAuth's error, its description, and the HTTP status.

HelperWhat it does
createPkce()A verifier and its S256 challenge
authorizeUrl(...)The address to send the browser to, and its state
parseRedirect(url, state)The code, after checking state, iss and error
exchangeCode(...)The token set
refreshToken(...)A new token set
revokeToken(...)Signs the person out
withRefresh(tokens, ...)An access token function that refreshes on its own

A browser app with no server

A public app has no secret. The tokens live in the page's memory, and the refresh token is replaced at every use.

ts
import { Plotwork } from '@plotwork.official/sdk'import { authorizeUrl, createPkce, exchangeCode, parseRedirect, withRefresh } from '@plotwork.official/sdk/oauth'
const clientId = 'your client id'const redirectUri = `${location.origin}/callback`
export async function signIn(): Promise<void> {  const pkce = await createPkce()  const { url, state } = authorizeUrl({ clientId, redirectUri, scopes: ['library:read'], codeChallenge: pkce.challenge })  sessionStorage.setItem('plotwork-sign-in', JSON.stringify({ state, verifier: pkce.verifier }))  location.assign(url)}
// On the page at redirectUri:export async function finishSignIn(): Promise<Plotwork> {  const { state, verifier } = JSON.parse(sessionStorage.getItem('plotwork-sign-in') ?? '{}')  sessionStorage.removeItem('plotwork-sign-in')  const code = parseRedirect(location.href, state)  const tokens = await exchangeCode({ clientId, code, redirectUri, codeVerifier: verifier })  return new Plotwork({ accessToken: withRefresh(tokens, { clientId, onRefresh: () => undefined }) })}

/v1, /oauth/token and /oauth/revoke answer any origin without credentials, so the page calls them from its own origin. A desktop or command line tool works the same way with a loopback redirect address such as http://127.0.0.1:8765/callback, registered with its port.

Python

plotwork.oauth has the same helpers for a server app, on the standard library alone:

python
import os
from plotwork import Plotworkfrom plotwork.oauth import authorize_url, create_pkce, exchange_code, parse_redirect, with_refresh
CLIENT_ID = os.environ["PLOTWORK_CLIENT_ID"]CLIENT_SECRET = os.environ["PLOTWORK_CLIENT_SECRET"]REDIRECT_URI = "https://example.com/auth/plotwork/callback"

def start(session):    pkce = create_pkce()    request = authorize_url(client_id=CLIENT_ID, redirect_uri=REDIRECT_URI, scopes=["library:read", "profile"], code_challenge=pkce.challenge)    session["plotwork_sign_in"] = {"state": request.state, "verifier": pkce.verifier}    return request.url  # redirect the browser here

def callback(session, url):    saved = session.pop("plotwork_sign_in")    code = parse_redirect(url, saved["state"])    session["plotwork_tokens"] = exchange_code(        client_id=CLIENT_ID, client_secret=CLIENT_SECRET, code=code, redirect_uri=REDIRECT_URI, code_verifier=saved["verifier"]    )

def client_for(session):    def save(tokens):        session["plotwork_tokens"] = tokens
    token = with_refresh(session["plotwork_tokens"], client_id=CLIENT_ID, client_secret=CLIENT_SECRET, on_refresh=save)    return Plotwork(access_token=token)

client_for(session).library.list() then lists the person's diagrams. A refusal raises PlotworkOAuthError.

Show private diagrams

With library:read, the viewer shows any diagram the person can see, private ones included, from /v1/library/{id}.svg with their token, and <plotwork-library> lets them pick one.

tsx
import { PlotworkDiagram, PlotworkLibrary } from '@plotwork.official/viewer/react'import { useState } from 'react'
export function Picker({ token }: { token: () => Promise<string> }) {  const [picture, setPicture] = useState<string | null>(null)  return (    <>      <PlotworkLibrary token={token} onSelect={({ pictureUrl }) => setPicture(pictureUrl)} />      {picture !== null && <PlotworkDiagram src={picture} token={token} live />}    </>  )}

The viewer sends the token only to an https address (or http on localhost), never with a cookie. A 401 is the signed-out event, and setting a fresh token loads the picture again. Show a private picture only to the person who signed in, never at a public address.

Organisations

TopicWhat happens
Connect asThe consent page offers the person's own account and each organisation where they may publish and meet its single sign-on rule. A connection to an organisation reads its library and spends its plan.
Members onlyAn organisation's app registered for members only can be connected by its members, to that organisation alone.
Allowed appsOn Enterprise, an owner or admin can turn on "Only approved apps can connect" in the organisation's Settings, Security and access, Allowed apps, and list up to 50 client ids. The organisation's own apps are always allowed. An app off the list cannot be connected to the organisation (403 oauth.app_not_allowed), and taking one off ends its connections.
What admins seeThe organisation's Developers, Apps shows its own apps and every app connected to it, who connected each, and Disconnect.
Audit eventsorgs.app.connect and orgs.app.disconnect (reason disconnected, replaced, not_allowed, member_left, revoked or reused), orgs.app.create, orgs.app.update, orgs.app.delete, orgs.app.rotate, and orgs.settings.allowed_apps, in the audit log. Never a token.
Call historyAn app's calls to an organisation are kept in its call history like calls made with its keys.

A connection to an organisation ends when the member who made it leaves or becomes a reader, or when the organisation is deleted.

Disconnect

A person ends a connection in the portal's Settings, under Connected apps. An organisation's owner and admins end any connection to the organisation from its Apps section. Deleting an app ends every connection to it. Your app signs a person out by revoking its refresh token. After any of these, the next call answers 401 oauth.invalid_token and the next refresh invalid_grant: delete what you hold for that person and offer the sign-in button again.

Limits and plans

RuleDetail
Who may registerEvery plan, up to 25 apps an account or organisation
Who may approveEvery plan
What a call spendsThe daily allowance and rate limits of the person or organisation that approved the app, as Plans and billing describes. A Free account's connection gets Free limits.
/v1/me and the libraryOpen on every plan, metered and rate-limited like any call
The token endpointRate-limited by address

Security

DoWhy
Treat the app name on the consent page as a claimAnyone can register an app with any name. People see where it sends them back beside it; so do you, when you approve one.
Keep a client secret on a serverNever put a secret, or a confidential app's refresh token, in a browser, a mobile app or a repository. A browser app registers as public.
Use PKCE and state on every sign-in, and check issThey stop a code from being stolen or injected. parseRedirect checks all three.
Keep tokens out of logs and addressesA token in a log or a query string can be read by anyone who sees it.
Make a new secret if one was seenThe old one stops working at once.

Building an app means accepting the developer terms: use what you receive only for the person's request, never sell it, and delete it when they disconnect.

The button

Start the sign-in from the Sign in with Plotwork button, for a light page or a dark page:

Both files, plotwork-sign-in-on-light.svg and plotwork-sign-in-on-dark.svg, are in the buttons folder of the brand kit (opens in a new tab). Serve a copy from your own site, or link them as below, and show them as they are, 40 pixels tall:

html
<a class="plotwork-sign-in" href="/auth/plotwork">  <picture>    <source media="(prefers-color-scheme: dark)" srcset="https://assets.plotwork.dev/brand/buttons/plotwork-sign-in-on-dark.svg">    <img src="https://assets.plotwork.dev/brand/buttons/plotwork-sign-in-on-light.svg" alt="Sign in with Plotwork" width="203" height="40">  </picture></a>

Or draw it in your own markup, with the same measurements and colours:

html
<a class="plotwork-sign-in-button" href="/auth/plotwork">  <svg viewBox="3 3 26 26" width="20" height="20" aria-hidden="true">    <defs>      <linearGradient id="plotwork-mark" gradientUnits="userSpaceOnUse" x1="2" y1="3" x2="30" y2="29">        <stop offset="0" stop-color="#c6f04a"/><stop offset="0.55" stop-color="#14b8a6"/><stop offset="1" stop-color="#0f766e"/>      </linearGradient>    </defs>    <path d="M3 3H14A11 11 0 0 1 3 14Z" fill="url(#plotwork-mark)"/>    <path d="M29 3V14A11 11 0 0 1 18 3Z" fill="url(#plotwork-mark)"/>    <path d="M29 29H18A11 11 0 0 1 29 18Z" fill="url(#plotwork-mark)"/>    <path d="M3 29V18A11 11 0 0 1 14 29Z" fill="#0f766e"/>  </svg>  <span>Sign in with Plotwork</span></a>
css
.plotwork-sign-in-button {  display: inline-flex;  align-items: center;  gap: 10px;  height: 40px;  padding: 0 16px;  border: 1px solid #dcdcdc;  border-radius: 8px;  background: #ffffff;  color: #171717;  font: 500 15px/1 'IBM Plex Sans', system-ui, sans-serif;  text-decoration: none;}
.plotwork-sign-in-button:focus-visible {  outline: 2px solid #0f766e;  outline-offset: 2px;}
@media (prefers-color-scheme: dark) {  .plotwork-sign-in-button {    border-color: #404040;    background: #141414;    color: #fafafa;  }}

In React, the same button as a component:

tsx
export function SignInWithPlotwork({ href = '/auth/plotwork' }: { href?: string }) {  return (    <a className="plotwork-sign-in-button" href={href}>      <svg viewBox="3 3 26 26" width="20" height="20" aria-hidden="true">        <defs>          <linearGradient id="plotwork-mark" gradientUnits="userSpaceOnUse" x1="2" y1="3" x2="30" y2="29">            <stop offset="0" stopColor="#c6f04a" />            <stop offset="0.55" stopColor="#14b8a6" />            <stop offset="1" stopColor="#0f766e" />          </linearGradient>        </defs>        <path d="M3 3H14A11 11 0 0 1 3 14Z" fill="url(#plotwork-mark)" />        <path d="M29 3V14A11 11 0 0 1 18 3Z" fill="url(#plotwork-mark)" />        <path d="M29 29H18A11 11 0 0 1 29 18Z" fill="url(#plotwork-mark)" />        <path d="M3 29V18A11 11 0 0 1 14 29Z" fill="#0f766e" />      </svg>      <span>Sign in with Plotwork</span>    </a>  )}

Keep the words and the mark as they are, and do not suggest that Plotwork made or endorses your app. The trademarks and icons page has the rules for the logo.

Troubleshooting

ErrorWhereMeansFix
oauth.invalid_client/oauth/authorize, 400No app has that client idCopy the client id from the portal again.
oauth.invalid_redirect_uri/oauth/authorize, 400The address is not one the app registeredRegister it exactly, with its port and path, or send one that is.
invalid_requestYour redirect address, or /oauth/tokenPKCE missing or not S256, a challenge of the wrong shape, a state too long, a missing field, a field sent twice, or a body that is not form-encodedSend every field once, form-encoded, with an S256 challenge.
invalid_scopeYour redirect address, or a refreshA scope Plotwork does not know, or a refresh asking for more than the connection holdsAsk only for the five scopes; a refresh may only narrow.
access_deniedYour redirect addressThe person said noShow the button again.
invalid_client/oauth/token, /oauth/revoke, 401The secret is wrong, sent twice, or missing for a confidential appSend the current secret once, by HTTP Basic or client_secret.
invalid_grant/oauth/token, 400The code or refresh token expired, was used already, belongs to another app or address, the verifier is wrong, or the connection endedSign the person in again. A reuse also ended the connection.
unsupported_grant_type/oauth/token, 400A grant other than authorization_code or refresh_tokenUse one of the two.
oauth.invalid_token/v1, 401The access token is unknown, expired or its connection endedRefresh, or sign in again.
oauth.insufficient_scope/v1, 403The connection lacks the scope in details.scopeAsk the person to approve the app again with that scope.
oauth.request_used, oauth.request_expired, oauth.request_invalidThe consent pageThe sign-in link was answered already, is past its ten minutes, or was not made by PlotworkStart again from the app.
oauth.app_members_onlyThe consent page, 403The app is an organisation's, for its members onlyConnect it as a member, to that organisation.
oauth.app_not_allowedThe consent page, 403The organisation allows only approved apps, and this is not oneAsk an owner or admin to add the client id under Allowed apps, or connect another account.
apps.invalidRegistering an app, 422A redirect address or homepage breaks the rules, or a public app was given a secretFollow the rules in Register an app.
apps.limit_reachedRegistering an app, 409The account or organisation already has 25 appsDelete one first.
oauth.unavailableAny endpoint, 503Sign in with Plotwork is not available on this serverUse https://api.plotwork.dev, or a server where it is turned on.

See also

PageWhy
SDKsThe TypeScript and Python clients and the viewer
Developer termsWhat an app may do with what it receives
API referenceEvery /v1 operation a token can call
Start Sign in with PlotworkThe authorize endpoint in detail
Trade a code or a refresh tokenThe token endpoint in detail
Hosted diagramsStable links and embed links, for pages with no sign-in
OrganisationsRoles, the private library and the owner's settings
Privacy policyWhat Plotwork keeps about a connection