Build a Plane App (BYOA)
Step-by-step development guide to build and integrate an app with Plane using OAuth-based authentication and authorization workflow.
Plane apps are currently in Beta. Please send any feedback to support@plane.so.
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, 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 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
- Register your app on Plane developer portal
- Implement OAuth flow
- Obtain and store access tokens securely
- Make authenticated API requests to Plane
- Handle token refresh
Registering Your App
To build an OAuth application with Plane:
-
Navigate to
https://app.plane.so/<workspace_slug>/settings/integrations/
. -
Click on the Build your own button.
-
Fill out the form with the required details:
- Setup URL: Provide the URL that users will be redirected to when they click "Install" from the marketplace or from the app listing. This URL should initiate the OAuth flow for your application.
- Redirect URIs: Provide the URIs where Plane will send the authorization code after the user consents to the app.
- Webhook URL Endpoint(Optional): Your service's webhook endpoint. Plane will send an HTTP
POST
request to this endpoint upon every change to the workspace in which your app was installed. - Contact Details: Add your email or other contact information.
- Organization Details(Optional): Optionally include your contact email, privacy policy URL, terms of service URL, and any other relevant information. This helps Plane validate and approve your application should you choose to list in the marketplace.
-
If you're building an agent (with or without using Plane's ADK) capable of performing operations when assigned or mentioned, enable the Enable App Mentions checkbox during app creation.
-
Once the app is created, securely store the generated 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.
Implement OAuth Flow
Generating Consent URL (Optional)
This step is optional. This is needed only if the app should be installed from outside Plane's environment, the developer needs to generate the consent URL using the client ID generated during their app creation flow.
If this flow needs to be triggered from Plane marketplace as well, then provide the URL in "Setup URL" field on application create screen to redirect the user from marketplace on clicking "Install App" button.
Below are sample implementations:
- Python
- TypeScript
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"),
}
consent_url = f"https://api.plane.so/auth/o/authorize-app/?{urlencode(params)}"
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!,
});
const consentUrl = `https://api.plane.so/auth/o/authorize-app/?${params.toString()}`;
There are two types of authenticated actions your application can perform:
- User-authorized actions: Actions performed on behalf of a user after they grant permission to your app via OAuth.
- 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).
For both these flows, Plane will make a GET request to the Redirect URI with parameters as mentioned in the following sections.
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 both a code
and 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.
Plane will make a GET request to the Redirect URI with below parameters:
Parameter | Description |
---|---|
code | Authorization code (present but not used for client credentials flow) |
app_installation_id | The unique identifier for the app installation in the workspace |
Examples
- Python
- TypeScript
import os
import time
from plane.oauth.api import OAuthApi
from plane.oauth.models import OAuthConfig
# Initialize OAuth API
def get_oauth_api():
oauth_config = OAuthConfig(
client_id=os.getenv("PLANE_CLIENT_ID"),
client_secret=os.getenv("PLANE_CLIENT_SECRET"),
redirect_uri=os.getenv("PLANE_REDIRECT_URI"),
)
return OAuthApi(
oauth_config=oauth_config,
base_url=os.getenv("PLANE_BASE_URL", "https://api.plane.so"),
)
# Get bot token using app installation ID
oauth_api = get_oauth_api()
token_response = oauth_api.get_bot_token(app_installation_id)
# Get app installation details
app_installations = oauth_api.get_app_installations(
token_response.access_token,
app_installation_id,
)
if not app_installations:
raise Exception(f"No app installations found for app installation ID {app_installation_id}")
app_installation = app_installations[0]
bot_token = token_response.access_token
expires_in = token_response.expires_in
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; // Token expiry in seconds
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.
Plane will make a GET request to the Redirect URI with below parameters:
Parameter | Description | Required |
---|---|---|
code | The authorization code that can be exchanged for an access token | Yes |
state | The state parameter that was passed in the authorization request | No |
Examples
- Python
- TypeScript
from plane.oauth.api import OAuthApi
from plane.oauth.models import OAuthConfig
# Initialize OAuth API
oauth_api = get_oauth_api() # Using the helper function from above
# Exchange authorization code for access and refresh tokens
code = "authorization_code_from_callback"
token_response = oauth_api.exchange_code_for_token(
code,
"authorization_code",
)
# Parse the response
access_token = token_response.access_token
refresh_token = token_response.refresh_token
expires_in = token_response.expires_in
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"
}
}
);
const responseData = response.data;
const accessToken = responseData.access_token;
const refreshToken = responseData.refresh_token;
const expiresIn = responseData.expires_in;
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.
Examples
- Python
- TypeScript
# Using the OAuth API to fetch app installation details
oauth_api = get_oauth_api()
app_installations = oauth_api.get_app_installations(
token, # Either access token or bot token
app_installation_id,
)
if app_installations:
workspace_details = app_installations[0]
print(f"Workspace: {workspace_details.workspace_detail.name}")
print(f"Workspace Slug: {workspace_details.workspace_detail.slug}")
print(f"Bot User ID: {workspace_details.app_bot}")
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];
Sample Response
The app installation endpoint returns an array of installation objects. Typically, you'll want to use the first element [0]
:
[
{
"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", // Bot user ID for your app in this workspace
"webhook": "b1f4b7f1-51e8-4919-a84c-0b1143b51d2c"
}
]
Key fields to note:
app_bot
: The bot user ID that represents your app in the workspaceworkspace_detail.slug
: The workspace slug needed for API callsworkspace
: The workspace ID for identifying the workspacestatus
: Should be "installed" for active installations
Webhook Payload Structure
When Plane sends webhooks to your application, the payload contains information about the event that occurred. Understanding this structure is crucial for processing webhooks effectively.
General Webhook Structure
All webhooks follow this general structure:
{
"event": "string", // Type of event (e.g., "issue", "issue_comment", "project")
"action": "string", // Action performed (e.g., "created", "updated", "deleted")
"webhook_id": "string", // Unique identifier for the webhook
"workspace_id": "string", // ID of the workspace where the event occurred
"data": { // Event-specific data (varies by event type)
"id": "string",
// ... other fields specific to the event
},
"activity": { // Information about who performed the action
"actor": {
"id": "string",
"first_name": "string",
"last_name": "string",
"email": "string",
"avatar": "string",
"display_name": "string"
},
"field": "string", // Field that was changed (for update events)
"new_value": "any", // New value (for update events)
"old_value": "any" // Previous value (for update events)
}
}
Common Event Types
Issue Comment Events
When someone comments on an issue:
{
"event": "issue_comment",
"action": "created",
"webhook_id": "uuid",
"workspace_id": "uuid",
"data": {
"id": "comment_id",
"issue": "issue_id",
"project": "project_id",
"workspace": "workspace_id",
"comment_html": "<p>Full HTML comment content</p>",
"comment_stripped": "Plain text comment content",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
},
"activity": {
"actor": {
"id": "user_id",
"display_name": "User Name",
"email": "user@example.com"
}
}
}
Issue Events
When an issue is created, updated, or deleted:
{
"event": "issue",
"action": "updated",
"webhook_id": "uuid",
"workspace_id": "uuid",
"data": {
"id": "issue_id",
"name": "Issue Title",
"description_html": "<p>Issue description</p>",
"priority": "high",
"project": "project_id",
"workspace": "workspace_id",
"assignees": ["user_id_1", "user_id_2"],
"labels": ["label_id_1"],
"state": {
"id": "state_id",
"name": "In Progress",
"color": "#f39c12"
},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
},
"activity": {
"actor": {
"id": "user_id",
"display_name": "User Name"
},
"field": "priority",
"new_value": "high",
"old_value": "medium"
}
}
Processing Webhooks
Here's how to process webhooks in your application:
- TypeScript
- Python
interface WebhookPayload {
event: string;
action: string;
webhook_id: string;
workspace_id: string;
data: any;
activity: {
actor: {
id: string;
display_name: string;
email?: string;
};
field?: string;
new_value?: any;
old_value?: any;
};
}
// Process incoming webhook
async function handleWebhook(payload: WebhookPayload) {
console.log(`Received ${payload.event} ${payload.action} event`);
// Get workspace credentials
const credentials = await getCredentialsForWorkspace(payload.workspace_id);
if (!credentials) {
throw new Error(`No credentials found for workspace ${payload.workspace_id}`);
}
// Process specific event types
if (payload.event === 'issue_comment' && payload.action === 'created') {
const comment = payload.data.comment_stripped;
if (comment.includes('/your-command')) {
// Handle your custom command
await processCommand(payload.data, credentials);
}
}
}
from typing import Dict, Any
from pydantic import ValidationError
def handle_webhook(payload_data: Dict[str, Any]):
"""Process incoming webhook from Plane with Pydantic validation"""
try:
# Validate webhook payload using Pydantic models
webhook = WebhookEvent(**payload_data)
print(f"Received {webhook.event} {webhook.action} event")
# Get workspace credentials (implement your own storage)
credentials = get_credentials_for_workspace(webhook.workspace_id)
if not credentials:
raise Exception(f"No credentials found for workspace {webhook.workspace_id}")
# Process specific event types with validated data
if webhook.event == 'issue_comment' and webhook.action == 'created':
comment_data = CommentEventData(**webhook.data)
comment_text = comment_data.comment_stripped or ""
if '/your-command' in comment_text:
process_command(comment_data, credentials)
elif webhook.event == 'issue' and webhook.action == 'updated':
issue_data = IssueEventData(**webhook.data)
if webhook.activity.field == 'assignees':
handle_assignment_change(issue_data, credentials)
except ValidationError as e:
print(f"Invalid webhook payload: {e}")
except Exception as e:
print(f"Error processing webhook: {e}")
def process_command(comment_data: CommentEventData, credentials):
"""Process custom commands from issue comments"""
from plane.api import WorkItemsApi
from plane.models import PatchedIssueRequest
# Use the Plane API to respond to commands
# Implementation depends on your specific command logic
pass
def handle_assignment_change(issue_data: IssueEventData, credentials):
"""Handle issue assignment changes"""
# Your custom logic for handling assignments
pass
Obtain and store access tokens securely
Once you have obtained the access token, you can use it to make authenticated API requests to Plane. Store the access token and refresh token securely in your database.
Make authenticated API requests to Plane
For making authenticated API requests to Plane, you can use the access token obtained from the OAuth flow.
API reference is available at https://docs.plane.so/api-reference.
We have official SDKs for the following languages to simplify the OAuth flow and make it easier to call Plane's API.
Language | Package Link | Source Code |
---|---|---|
Node.js | npm i @makeplane/plane-node-sdk | plane-node-sdk |
Python | pip install plane-sdk | plane-python-sdk |
Handle Token Refresh
Token refresh works differently depending on the type of token you're using:
Bot Token Refresh (Client Credentials Flow)
Bot tokens obtained through the client credentials flow don't use refresh tokens. Instead, when a bot token expires, you simply request a new one using the same app_installation_id
:
- Python
- TypeScript
# When bot token expires, request a new one using the same app_installation_id
from plane.oauth.api import OAuthApi
def refresh_bot_token(app_installation_id: str):
"""Refresh an expired bot token"""
oauth_api = get_oauth_api() # Using helper function from earlier examples
# Get new bot token using the same app_installation_id
token_response = oauth_api.get_bot_token(app_installation_id)
# Store the new token securely in your database
new_bot_token = token_response.access_token
expires_in = token_response.expires_in
return new_bot_token, expires_in
# Usage example
new_token, expires_in = refresh_bot_token(app_installation_id)
// When bot token expires, request a new one using the same app_installation_id
import axios from 'axios';
const clientId = "your_client_id";
const clientSecret = "your_client_secret";
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const payload = {
grant_type: "client_credentials",
app_installation_id: appInstallationId // Same ID used during initial setup
};
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 newBotToken = responseData.access_token;
const expiresIn = responseData.expires_in;
User Token Refresh (Authorization Code Flow)
When user access tokens expire, you can use the refresh token to get a new access token:
Examples
- Python
- TypeScript
# When access token expires, use refresh token to get a new access token
from plane.oauth.api import OAuthApi
def refresh_user_token(refresh_token: str):
"""Refresh an expired user access token"""
oauth_api = get_oauth_api() # Using helper function from earlier examples
# Use refresh token to get new access token
token_response = oauth_api.exchange_code_for_token(
refresh_token,
"refresh_token",
)
# Store the new tokens securely
new_access_token = token_response.access_token
new_refresh_token = token_response.refresh_token # May be the same or new
expires_in = token_response.expires_in
return new_access_token, new_refresh_token, expires_in
# Usage example
new_access_token, new_refresh_token, expires_in = refresh_user_token(stored_refresh_token)
// 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"
}
}
refresh_response = requests.post(
url="https://api.plane.so/auth/o/token/",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=refresh_payload
)
refresh_response_data = refresh_response.json()
access_token = refresh_response_data["access_token"]
Listing Your App on Plane Marketplace
Apps built using the OAuth flow can be listed on the Plane Marketplace: https://plane.so/marketplace/integrations
To list your app, please contact the Plane team at support@plane.so.