-
Notifications
You must be signed in to change notification settings - Fork 10
[DOCSW-379] feat: add doc for building plane app #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
a7251fd
1fa1048
91892fb
31f11b4
a858faa
f3a7aad
85267b7
f694b53
a27648a
00c3ff5
777a6e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,397 @@ | ||||||
| --- | ||||||
| title: Build a Plane App (Beta) | ||||||
| sidebarTitle: Build a Plane App (Beta) | ||||||
| description: Step-by-step development guide to build and integrate an app with Plane using OAuth-based authentication and authorization workflow. | ||||||
| --- | ||||||
|
|
||||||
| <Note>Plane apps are currently in **Beta**. Please send any feedback to support@plane.so.</Note> | ||||||
|
|
||||||
| ## Introduction | ||||||
| Plane apps seamlessly integrate tools and services with Plane so you can | ||||||
| use them without ever leaving your Workspace. Apps are conveniently available | ||||||
| from our [marketplace](https://plane.so/marketplace/integrations), helping you | ||||||
| stay focused and productive. | ||||||
|
|
||||||
| ## Why Build a Plane App? | ||||||
|
|
||||||
| **Stop doing manual work.** | ||||||
| Plane integrations eliminate repetitive tasks like copying updates between | ||||||
| tools, creating work items from support tickets, and generating status reports. | ||||||
| Instead of spending hours on administrative work, let your app handle it | ||||||
| automatically. | ||||||
|
|
||||||
| **Connect everything you already use.** | ||||||
| Your team probably uses dozens of different tools. Plane apps create a unified | ||||||
| workflow by connecting your favorite CRM, time tracking app, CI/CD pipelines, | ||||||
| communication tools, and more, together into Plane. One change in Plane can | ||||||
| trigger updates across your entire tech stack. | ||||||
|
|
||||||
| **Build exactly what you need.** | ||||||
| Unlike rigid SaaS platforms, Plane's open core nature means you can create | ||||||
| integrations that fit your specific workflow. | ||||||
|
|
||||||
| ## Prerequisites | ||||||
|
|
||||||
| - A [Plane](https://app.plane.so) workspace | ||||||
| - Admin access to your workspace settings | ||||||
| - Familiarity with OAuth 2.0 concepts (authorization code flow) | ||||||
| - A backend server to handle OAuth token exchange | ||||||
|
|
||||||
| ## High-Level Workflow | ||||||
|
|
||||||
| 1. [Register your app on Plane developer portal](/guides/build-plane-app/#registering-your-app) | ||||||
| 2. [Implement OAuth authorization code flow](/guides/build-plane-app#authentication-setup) | ||||||
| 3. Obtain and store access tokens securely | ||||||
| 4. Make authenticated API requests to Plane | ||||||
| 5. Handle token refresh | ||||||
|
|
||||||
| ## Registering Your App | ||||||
|
|
||||||
| To build an OAuth application with Plane: | ||||||
|
|
||||||
| 1. Navigate to `https://app.plane.so/<workspace_slug>/settings/applications/`. | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
| 2. Click on the **Build your own** button. | ||||||
| 3. Fill out the form with the required details: | ||||||
|
|
||||||
| - **Redirect URIs**: Provide the URIs where Plane will send the authorization code. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
We also need to demonstrate in this guide an example of an app redirect endpoint, what data it should expect to receive and how, and any expectations we have on them (should they respond with HTTP status 200, 204, etc) |
||||||
| - **Contact Details**: Add your email or other contact information. | ||||||
| - **Webhook URL Endpoint**: Update this with your service's webhook endpoint. Plane will send webhooks for all changes that happen in the installed workspace. | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
| - **Organization Details**: Include contact email, privacy policy URL, terms of service URL, and any other relevant information. This helps Plane validate and approve your application for listing in the marketplace. | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| 4. If you're building an agent (with or without using Plane's ADK) capable of performing operations when assigned or mentioned, enable the **Is Mentionable** checkbox during app creation. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We enabled Is Mentionable by default. This will result in all apps coming in assignees and other user fields. It'll be noise if there are too many apps in the assignees/members list. |
||||||
| 5. Once the app is created, securely store the **Client ID** and **Client Secret**. You will need these credentials to interact with Plane's API during the OAuth flow and for making authenticated API requests. | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
|
Prashant-Surya marked this conversation as resolved.
|
||||||
| ## Authentication Setup | ||||||
|
|
||||||
| ### Generating Consent URL | ||||||
|
|
||||||
| Before handling authentication, if your app manages installation, you must generate the consent (authorization) URL to initiate the OAuth flow. Below are sample implementations: | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We need to define what managing installation means before we reference it. When I read this, I wonder if I missed something and I scroll back up to scan for anything that might be related to managing installation.
If generating a consent URL is optional, I recommend moving this section later in the guide, to a location next to where the consent URL is required. |
||||||
|
|
||||||
| <Tabs> | ||||||
| <Tab title="Python"> | ||||||
|
|
||||||
| ```python | ||||||
| import os | ||||||
| from urllib.parse import urlencode | ||||||
|
|
||||||
| params = { | ||||||
| "client_id": os.getenv("PLANE_CLIENT_ID"), | ||||||
| "response_type": "code", | ||||||
| "redirect_uri": os.getenv("PLANE_REDIRECT_URI"), | ||||||
| # Optional: include state if needed | ||||||
| } | ||||||
|
|
||||||
| consent_url = f"https://api.plane.so/auth/o/authorize-app/?{urlencode(params)}" | ||||||
| ``` | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| </Tab> | ||||||
| <Tab title="TypeScript"> | ||||||
|
|
||||||
| ```typescript | ||||||
| import { URLSearchParams } from 'url'; | ||||||
|
|
||||||
| const params = new URLSearchParams({ | ||||||
| client_id: process.env.PLANE_CLIENT_ID!, | ||||||
| response_type: "code", | ||||||
| redirect_uri: process.env.PLANE_REDIRECT_URI!, | ||||||
| // Optional: include state if needed | ||||||
| }); | ||||||
|
|
||||||
| const consentUrl = `https://api.plane.so/auth/o/authorize-app/?${params.toString()}`; | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| </Tabs> | ||||||
|
|
||||||
| There are two types of authenticated actions your application can perform: | ||||||
|
|
||||||
| 1. **User-authorized actions**: Actions performed on behalf of a user after they grant permission to your app via OAuth. | ||||||
| 2. **App-authorized actions**: Actions that the app can perform independently within the workspace where it is installed (such as responding to webhooks or automation triggers). | ||||||
|
|
||||||
| We will describe how to configure and use each type in the following sections. | ||||||
|
|
||||||
| ### App-Authorized Actions (Client Credentials Flow) | ||||||
|
|
||||||
| When the app is installed, Plane will send an `app_installation_id` as part of the callback to the `redirect_uri` provided during consent URL generation. You can use this `app_installation_id` to request a bot token for your app. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the other flow, we tell the developer to securely store the returned token. Let's do that here too. |
||||||
|
|
||||||
| #### Examples | ||||||
|
|
||||||
| <Tabs> | ||||||
| <Tab title="Python"> | ||||||
|
|
||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
| ```python | ||||||
| import base64 | ||||||
| import requests | ||||||
|
|
||||||
| # Prepare basic auth header using client_id and client_secret | ||||||
| client_id = "your_client_id" | ||||||
| client_secret = "your_client_secret" | ||||||
| basic_auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() | ||||||
|
|
||||||
| # Prepare request data | ||||||
| payload = { | ||||||
| "grant_type": "client_credentials", | ||||||
| "app_installation_id": app_installation_id | ||||||
| } | ||||||
|
|
||||||
| # Make a POST request to fetch bot token | ||||||
| response = requests.post( | ||||||
| url="https://api.plane.so/auth/o/token/", | ||||||
| headers={"Authorization": f"Basic {basic_auth}", | ||||||
| "Content-Type": "application/x-www-form-urlencoded"}, | ||||||
| data=payload | ||||||
| ) | ||||||
|
|
||||||
| # Parse the response | ||||||
| response_data = response.json() | ||||||
| bot_token = response_data['access_token'] | ||||||
| expires_in = response_data["expires_in"] | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| <Tab title="TypeScript"> | ||||||
|
|
||||||
| ```typescript | ||||||
| import axios from 'axios'; | ||||||
|
|
||||||
| // Prepare basic auth header using client_id and client_secret | ||||||
| const clientId = "your_client_id"; | ||||||
| const clientSecret = "your_client_secret"; | ||||||
| const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); | ||||||
|
|
||||||
| // Prepare request data | ||||||
| const payload = { | ||||||
| grant_type: "client_credentials", | ||||||
| app_installation_id: appInstallationId | ||||||
| }; | ||||||
|
|
||||||
| // Make a POST request to fetch bot token | ||||||
| const response = await axios.post( | ||||||
| "https://api.plane.so/auth/o/token/", | ||||||
| payload, | ||||||
| { | ||||||
| headers: { | ||||||
| Authorization: `Basic ${basicAuth}`, | ||||||
| "Content-Type": "application/x-www-form-urlencoded" | ||||||
| } | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| // Parse the response | ||||||
| const responseData = response.data; | ||||||
| const botToken = responseData.access_token; | ||||||
| const expiresIn = responseData.expires_in; | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| </Tabs> | ||||||
|
|
||||||
| ### User-Authorized Actions (Authorization Code Flow) | ||||||
|
|
||||||
| In this flow, your app exchanges the `code` received as a query parameter on the callback (to your `redirect_uri`) for an access token and refresh token. The access token is short-lived and must be refreshed using the refresh token when it expires. Both tokens should be securely stored. | ||||||
|
|
||||||
| #### Examples | ||||||
|
|
||||||
| <Tabs> | ||||||
| <Tab title="Python"> | ||||||
|
|
||||||
| ```python | ||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
| import requests | ||||||
|
|
||||||
| # Exchange authorization code for access and refresh tokens | ||||||
| code = "authorization_code_from_callback" | ||||||
| client_id = "your_client_id" | ||||||
| client_secret = "your_client_secret" | ||||||
| redirect_uri = "your_redirect_uri" | ||||||
|
|
||||||
| payload = { | ||||||
| "grant_type": "authorization_code", | ||||||
| "code": code, | ||||||
| "client_id": client_id, | ||||||
| "client_secret": client_secret, | ||||||
| "redirect_uri": redirect_uri | ||||||
| } | ||||||
|
|
||||||
| response = requests.post( | ||||||
| url="https://api.plane.so/auth/o/token/", | ||||||
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | ||||||
| data=payload | ||||||
| ) | ||||||
|
|
||||||
| # Parse the response | ||||||
| response_data = response.json() | ||||||
| access_token = response_data["access_token"] | ||||||
| refresh_token = response_data["refresh_token"] | ||||||
| expires_in = response_data["expires_in"] | ||||||
|
|
||||||
| # When access token expires, use refresh token to get a new access token | ||||||
| refresh_payload = { | ||||||
| "grant_type": "refresh_token", | ||||||
| "refresh_token": refresh_token, | ||||||
| "client_id": client_id, | ||||||
| "client_secret": client_secret | ||||||
| } | ||||||
|
|
||||||
| refresh_response = requests.post( | ||||||
| url="https://api.plane.so/auth/o/token/", | ||||||
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | ||||||
| data=refresh_payload | ||||||
| ) | ||||||
|
|
||||||
| # Parse the refresh response | ||||||
| refresh_response_data = refresh_response.json() | ||||||
| access_token = refresh_response_data["access_token"] | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| <Tab title="TypeScript"> | ||||||
|
|
||||||
| ```typescript | ||||||
| import axios from 'axios'; | ||||||
|
|
||||||
| // Exchange authorization code for access and refresh tokens | ||||||
| const code = "authorization_code_from_callback"; | ||||||
| const clientId = "your_client_id"; | ||||||
| const clientSecret = "your_client_secret"; | ||||||
| const redirectUri = "your_redirect_uri"; | ||||||
|
|
||||||
| const payload = { | ||||||
| grant_type: "authorization_code", | ||||||
| code: code, | ||||||
| client_id: clientId, | ||||||
| client_secret: clientSecret, | ||||||
| redirect_uri: redirectUri | ||||||
| }; | ||||||
|
|
||||||
| const response = await axios.post( | ||||||
| "https://api.plane.so/auth/o/token/", | ||||||
| payload, | ||||||
| { | ||||||
| headers: { | ||||||
| "Content-Type": "application/x-www-form-urlencoded" | ||||||
| } | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| // Parse the response | ||||||
| const responseData = response.data; | ||||||
| const accessToken = responseData.access_token; | ||||||
| const refreshToken = responseData.refresh_token; | ||||||
| const expiresIn = responseData.expires_in; | ||||||
|
|
||||||
| // When access token expires, use refresh token to get a new access token | ||||||
| const refreshPayload = { | ||||||
| grant_type: "refresh_token", | ||||||
| refresh_token: refreshToken, | ||||||
| client_id: clientId, | ||||||
| client_secret: clientSecret | ||||||
| }; | ||||||
|
|
||||||
| const refreshResponse = await axios.post( | ||||||
| "https://api.plane.so/auth/o/token/", | ||||||
| refreshPayload, | ||||||
| { | ||||||
| headers: { | ||||||
| "Content-Type": "application/x-www-form-urlencoded" | ||||||
| } | ||||||
| } | ||||||
| ); | ||||||
|
|
||||||
| // Parse the refresh response | ||||||
| const refreshResponseData = refreshResponse.data; | ||||||
| const accessToken = refreshResponseData.access_token; | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| </Tabs> | ||||||
|
|
||||||
| ### Fetching App Installation Details | ||||||
|
|
||||||
| In both user-authorized and app-authorized flows, the `app_installation_id` identifies the app's installation within a specific workspace. It is recommended that developers fetch workspace details after OAuth is successfully completed. Plane provides an `app-installation` endpoint that works with both types of tokens. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Is it possible to use the API to fetch workspace details if Oauth hasn't been completed? Should we remove this line?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Are you referring to the |
||||||
|
|
||||||
| #### Examples | ||||||
|
|
||||||
| <Tabs> | ||||||
| <Tab title="Python"> | ||||||
|
|
||||||
|
Prashant-Surya marked this conversation as resolved.
Outdated
|
||||||
| ```python | ||||||
| import requests | ||||||
|
|
||||||
| # Set authorization header with either access token or bot token | ||||||
| headers = { | ||||||
| "Authorization": f"Bearer {token}", | ||||||
| } | ||||||
|
|
||||||
| # Make GET request to fetch installation/workspace details | ||||||
| response = requests.get( | ||||||
| url=f"https://api.plane.so/auth/o/app-installation/?id={app_installation_id}", | ||||||
| headers=headers | ||||||
| ) | ||||||
|
|
||||||
| workspace_details = response.data[0] | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| <Tab title="TypeScript"> | ||||||
|
|
||||||
| ```typescript | ||||||
| import axios from 'axios'; | ||||||
|
|
||||||
| // Set authorization header with either access token or bot token | ||||||
| const headers = { | ||||||
| Authorization: `Bearer ${token}`, | ||||||
| }; | ||||||
|
|
||||||
| // Make GET request to fetch installation/workspace details | ||||||
| const response = await axios.get( | ||||||
| `https://api.plane.so/auth/o/app-installation/?id=${app_installation_id}`, | ||||||
| { headers } | ||||||
| ); | ||||||
|
|
||||||
| const workspaceDetails = response.data[0]; | ||||||
| ``` | ||||||
|
|
||||||
| </Tab> | ||||||
| </Tabs> | ||||||
|
|
||||||
| #### Sample Response | ||||||
|
|
||||||
| ``` | ||||||
| [ | ||||||
| { | ||||||
| "id": "34b97361-8636-43dc-953e-90deedc8498f", | ||||||
| "workspace_detail": { | ||||||
| "name": "sandbox", | ||||||
| "slug": "sandbox", | ||||||
| "id": "7a2e5944-c117-4a7d-b5f4-058fe705d7d1", | ||||||
| "logo_url": null | ||||||
| }, | ||||||
| "created_at": "2025-05-16T13:50:27.865821Z", | ||||||
| "updated_at": "2025-06-23T08:57:26.976742Z", | ||||||
| "deleted_at": null, | ||||||
| "status": "installed", | ||||||
| "workspace": "7a2e5944-c117-4a7d-b5f4-058fe705d7d1", | ||||||
| "application": "ab235529-388a-4f51-a55a-78272251f5f1", | ||||||
| "installed_by": "63333ab1-c605-42fc-82f7-5cd86799eca1", | ||||||
| "app_bot": "7286aaa7-9250-4851-a520-29c904fd7654", // app's bot user id in the workspace | ||||||
| "webhook": "b1f4b7f1-51e8-4919-a84c-0b1143b51d2c" | ||||||
| } | ||||||
| ] | ||||||
| ``` | ||||||
|
|
||||||
| ## Using Plane SDKs | ||||||
|
|
||||||
| To simplify the OAuth flow and make it easier to build Plane apps, official SDKs are available: | ||||||
|
|
||||||
| | Language | Package Link | Source Code | | ||||||
| |----------|---------|-------------| | ||||||
| | Node.js | [npm i @makeplane/plane-node-sdk](https://www.npmjs.com/package/@makeplane/plane-node-sdk) | [plane-node-sdk](https://github.com/makeplane/plane-node-sdk) | | ||||||
| | Python | [pip install plane-sdk](https://pypi.org/project/plane-sdk/) | [plane-python-sdk](https://github.com/makeplane/plane-python-sdk) | | ||||||
|
|
||||||
| These SDKs provide helpers for OAuth and other common tasks, allowing developers to implement the above flows efficiently. | ||||||
|
|
||||||
| ## Listing Your App on Plane Marketplace | ||||||
|
|
||||||
| Apps built using the OAuth flow can be listed on the Plane Marketplace: [https://plane.so/marketplace/integrations](https://plane.so/marketplace/integrations) | ||||||
|
|
||||||
| To list your app, please contact the Plane team at [**support@plane.so**](mailto:support@plane.so). | ||||||
Uh oh!
There was an error while loading. Please reload this page.