Addon Developer API
This guide explains how to create an addon for the {productName} marketplace. Your addon is registered in the system with a set of URLs that the platform calls at specific lifecycle moments — activation, deactivation, pause/unpause — and URLs that the user can open for configuration and additional views.
Overview
An addon version record contains the following URL fields. Each field is optional; only provide the ones your addon requires.
| Field | When called | Method |
|---|---|---|
activationUrl | User installs the addon | POST |
deactivationUrl | User uninstalls the addon | POST |
pauseUnpauseUrl | User pauses or resumes the addon | POST |
configUrl | User clicks “Configure” | Opened in browser (iframe or link) |
statusUrl | Platform polls for current addon status | GET |
Additionally, the urls array holds extra links that appear in the addon detail sidebar (e.g. logs, reports). See the URLs list section below.
API Token
When the platform activates your addon it generates a JWT bearer token tied to that specific installation. This token is passed to your activationUrl as apiToken in the activation payload and should be stored securely by your service.
Use it in subsequent calls back to {productName} (e.g. reading or writing product data via the Query API) as a standard Authorization: Bearer <token> header.
The token is scoped to the organisation that installed the addon. It has read/write permissions and is valid indefinitely. When the addon is uninstalled the token is expired automatically within 15 minutes.
tab: TypeScript
async function fetchProducts(apiToken: string, hostname: string): Promise<any[]> {
const response = await fetch(`{HOSTNAME}/api/query`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: 'products { id, name, price }' }),
});
if (!response.ok) throw new Error(`Query failed: ${response.status}`);
const data = await response.json();
return data.products ?? [];
}
---
tab: PHP
function fetchProducts(string $apiToken): array {
$ch = curl_init("{hostname}/api/query");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $apiToken",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['query' => 'products { id, name, price }']),
]);
$body = curl_exec($ch);
curl_close($ch);
$data = json_decode($body, true);
return $data['products'] ?? [];
}
Lifecycle URLs
activationUrl — Addon Installed
Called once when a user installs your addon. Use it to provision resources (e.g. create a tenant/workspace in your service), store the apiToken, and prepare any initial configuration.
Method: POST
Expected response: { "success": true } — any non-success response or HTTP error causes the installation to fail.
You may include an optional `data` field in the response. Its value (any JSON-serialisable object) will be forwarded as-is to the frontend response, which is useful for debugging activation issues from the browser's developer tools.
```json
{ "success": true, "data": { "workspaceId": "ws-123", "plan": "trial" } }
**Request payload:**
```typescript
interface ActivationPayload {
myAddon_id: string; // Unique ID of this installation
addon_id: string; // Addon definition ID
tenant_id: string; // {productName} tenant ID
organisation_id: string; // Organisation that installed the addon
// API token — store this to authenticate future calls back to {productName}
apiToken: string; // JWT bearer token
access_token: string; // Same as apiToken (alias for compatibility)
api_token_id: string; // Database ID of the token record
// Signing secret — store this to verify ps_token JWTs on incoming configUrl / urls[] / statusUrl requests
psSigningSecret: string;
organisation: {
id: string;
code: string;
name: string;
email: string | null;
vatId: string | null;
registrationNumber: string | null;
taxId: string | null;
defaultLanguage: string; // e.g. "en", "cs"
defaultTimezone: string; // e.g. CET
flavours: Record<string, any> | null;
country: string | null; // ISO country code
};
tenant: {
id: string;
code: string;
name: string;
};
user: {
id: string;
username: string;
firstname: string | null;
surname: string | null;
email: string | null;
telephone: string | null;
jobTitle: string | null;
};
}
tab: TypeScript
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
// In-memory store (use a real database in production)
const installations = new Map<string, {
apiToken: string;
psSigningSecret: string;
organisationId: string;
}>();
app.post('/addon/activate', (req: Request, res: Response) => {
const { myAddon_id, apiToken, psSigningSecret, organisation } = req.body;
if (!myAddon_id || !apiToken || !psSigningSecret) {
return res.status(400).json({ success: false, error: 'Missing required fields' });
}
// Store both the API token and the signing secret
installations.set(myAddon_id, {
apiToken,
psSigningSecret,
organisationId: organisation.id,
});
console.log(`Addon installed for org: ${organisation.name} (${organisation.id})`);
res.json({ success: true });
});
---
tab: PHP
// activate.php
$payload = json_decode(file_get_contents('php://input'), true);
$myAddonId = $payload['myAddon_id'] ?? null;
$apiToken = $payload['apiToken'] ?? null;
$psSigningSecret = $payload['psSigningSecret'] ?? null;
$organisation = $payload['organisation'] ?? null;
if (!$myAddonId || !$apiToken || !$psSigningSecret) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing required fields']);
exit;
}
// Persist installation (use a real DB in production)
file_put_contents(
__DIR__ . "/installations/$myAddonId.json",
json_encode([
'apiToken' => $apiToken,
'psSigningSecret' => $psSigningSecret,
'organisationId' => $organisation['id'],
])
);
echo json_encode(['success' => true]);
deactivationUrl — Addon Uninstalled
Called when the user uninstalls the addon. Clean up provisioned resources and invalidate any stored tokens.
Method: POST
Expected response: { "success": true } — you may include an optional data field (any JSON object) which will be forwarded to the frontend response and is useful for debugging (visible in the browser’s developer tools).
A 404 response from your endpoint is treated as success (idempotent uninstall). If you return a non-success response the platform logs the error but still marks the addon as uninstalled.
Request payload:
interface DeactivationPayload {
myAddon_id: string;
organisation_id: string;
}
tab: TypeScript
app.post('/addon/deactivate', (req: Request, res: Response) => {
const { myAddon_id } = req.body;
installations.delete(myAddon_id);
console.log(`Addon uninstalled: ${myAddon_id}`);
res.json({ success: true });
});
---
tab: PHP
// deactivate.php
$payload = json_decode(file_get_contents('php://input'), true);
$myAddonId = $payload['myAddon_id'] ?? null;
if ($myAddonId) {
@unlink(__DIR__ . "/installations/$myAddonId.json");
}
echo json_encode(['success' => true]);
pauseUnpauseUrl — Addon Paused / Resumed
Called when the user pauses or resumes the addon. Pause/unpause use the same URL — distinguish the action using the action field in the payload.
Method: POST
Expected response: { "success": true } — you may include an optional data field (any JSON object) which will be forwarded to the frontend response and is useful for debugging (visible in the browser’s developer tools).
Request payload:
interface PauseUnpausePayload {
myAddon_id: string;
addon_id: string;
tenant_id: string;
organisation_id: string;
action: 'pause' | 'unpause';
organisation: { /* same shape as in activation */ };
tenant: { /* same shape as in activation */ };
// Note: user is NOT included in pause/unpause payloads
}
tab: TypeScript
app.post('/addon/pause-unpause', (req: Request, res: Response) => {
const { myAddon_id, action } = req.body;
if (action !== 'pause' && action !== 'unpause') {
return res.status(400).json({ success: false, error: 'Invalid action' });
}
const installation = installations.get(myAddon_id);
if (!installation) {
return res.status(404).json({ success: false, error: 'Installation not found' });
}
// Pause or resume your background jobs / sync processes here
if (action === 'pause') {
console.log(`Pausing addon ${myAddon_id}`);
} else {
console.log(`Resuming addon ${myAddon_id}`);
}
res.json({ success: true });
});
---
tab: PHP
// pause-unpause.php
$payload = json_decode(file_get_contents('php://input'), true);
$myAddonId = $payload['myAddon_id'] ?? null;
$action = $payload['action'] ?? null;
if (!in_array($action, ['pause', 'unpause'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid action']);
exit;
}
// Store desired state, adjust background jobs, etc.
$state = $action === 'pause' ? 'paused' : 'active';
// ... your business logic here ...
echo json_encode(['success' => true]);
configUrl — Configuration UI
The configuration URL is opened when the user clicks the Configure button in the addon detail page. It is not a webhook — your URL is opened directly in the browser. You can choose how to present it using configUrlType.
configUrlType: "link" vs configUrlType: "iframe"
| Type | Behaviour |
|---|---|
link | The URL opens in a new browser tab. The user leaves the {productName} UI. Suitable for complex multi-page configuration wizards. |
iframe | The URL is embedded in an inline panel inside the {productName} UI. The user stays in context. Suitable for lightweight settings forms. |
When using `iframe`, make sure your page sets appropriate `Content-Security-Policy` and `X-Frame-Options` headers (or omits them) to allow being embedded. A restrictive `X-Frame-Options: DENY` will prevent the iframe from loading.
Before opening your configUrl in the browser, the platform appends the following query parameters:
| Parameter | Description |
|---|---|
ps_token | Short-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform. |
myAddon_id | ID of the addon installation. |
organisation_id | ID of the organisation. |
tenant_id | ID of the tenant. |
user_id | ID of the user who triggered the action. |
language | Current UI language of the user (e.g. en, cs). |
The `ps_token` JWT contains two claims: `sub` (`myAddon_id`) and `org` (`organisation_id`). The plain query params are provided for convenience — always verify the JWT signature before trusting any of them.
Example addon version fields:
{
"configUrl": "https://your-addon.example.com/config",
"configUrlType": "iframe"
}
tab: TypeScript
import jwt from 'jsonwebtoken';
app.get('/config', (req: Request, res: Response) => {
const psConfig = req.query.ps_token as string | undefined;
const myAddonId = req.query.myAddon_id as string | undefined;
if (!psConfig || !myAddonId) {
return res.status(401).send('<p>Missing ps_token</p>');
}
const installation = installations.get(myAddonId);
if (!installation) {
return res.status(403).send('<p>Unknown installation</p>');
}
// Verify signature and expiry using the stored psSigningSecret
try {
jwt.verify(psConfig, installation.psSigningSecret);
} catch {
return res.status(401).send('<p>Invalid or expired ps_token</p>');
}
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Addon Configuration</title>
<style>body { font-family: sans-serif; padding: 1rem; }</style>
</head>
<body>
<h2>Configure Addon</h2>
<form method="POST" action="/config/save">
<input type="hidden" name="myAddon_id" value="${myAddonId}">
<label>Sync interval (minutes):
<input type="number" name="interval" value="30" min="5">
</label>
<button type="submit">Save</button>
</form>
</body>
</html>
`);
});
---
tab: PHP
// config.php
// Requires firebase/php-jwt: composer require firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$psConfig = $_GET['ps_token'] ?? null;
$myAddonId = $_GET['myAddon_id'] ?? null;
if (!$psConfig || !$myAddonId) {
http_response_code(401);
echo '<p>Missing ps_token</p>';
exit;
}
$installationFile = __DIR__ . "/installations/$myAddonId.json";
if (!file_exists($installationFile)) {
http_response_code(403);
echo '<p>Unknown installation</p>';
exit;
}
$installation = json_decode(file_get_contents($installationFile), true);
$psSigningSecret = $installation['psSigningSecret'] ?? null;
// Verify signature and expiry
try {
JWT::decode($psConfig, new Key($psSigningSecret, 'HS256'));
} catch (\Exception $e) {
http_response_code(401);
echo '<p>Invalid or expired ps_token: ' . htmlspecialchars($e->getMessage()) . '</p>';
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Addon Configuration</title>
<style>body { font-family: sans-serif; padding: 1rem; }</style>
</head>
<body>
<h2>Configure Addon</h2>
<form method="POST" action="/config/save.php">
<input type="hidden" name="myAddon_id" value="<?= htmlspecialchars($myAddonId) ?>">
<label>Sync interval (minutes):
<input type="number" name="interval" value="30" min="5">
</label>
<button type="submit">Save</button>
</form>
</body>
</html>
statusUrl — Addon Status
If you provide a statusUrl, the platform will call it to display the current state of your addon to the user (shown in the addon detail sidebar with a colour-coded icon).
Method: GET
Authentication: Before calling your statusUrl, the platform appends the following query parameters:
| Parameter | Description |
|---|---|
ps_token | Short-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform. |
myAddon_id | ID of the addon installation. |
organisation_id | ID of the organisation. |
tenant_id | ID of the tenant. |
user_id | ID of the user who triggered the action. |
language | Current UI language of the user (e.g. en, cs). |
The `ps_token` JWT contains two claims: `sub` (`myAddon_id`) and `org` (`organisation_id`). The plain query params are provided for convenience — always verify the JWT signature before trusting any of them.
Response format:
interface AddonStatusResponse {
/** Current state of the addon */
status: 'ok' | 'paused' | 'blocked' | 'invalid-auth' | 'error';
/** ISO 8601 timestamp of the last successful operation, or null */
lastSuccessAt: string | null;
/** ISO 8601 timestamp of the last failure, or null */
lastFailureAt: string | null;
/**
* Optional human-readable message describing the current state.
* Can be a plain string or a TranslatableString object (language → message map).
*/
statusMessage: string | Record<string, string> | null;
/**
* Optional arbitrary data passed through to the frontend response as-is.
* Not shown in the UI — visible only in the browser's developer tools (Network tab).
* Useful for surfacing diagnostic information without a separate debug endpoint.
*/
data?: Record<string, any>;
}
Status values and their meaning:
| Status | Colour | Meaning |
|---|---|---|
ok | Green | The addon is running normally |
paused | Yellow | The addon is paused by the user |
blocked | Orange | Processing is blocked (e.g. rate limit, upstream issue) |
invalid-auth | Red | Authentication to an external service failed |
error | Red | An unexpected error occurred |
tab: TypeScript
app.get('/addon/status', (req: Request, res: Response) => {
const psConfig = req.query.ps_token as string | undefined;
const myAddonId = req.query.myAddon_id as string | undefined;
if (!psConfig || !myAddonId) {
return res.status(401).json({ error: 'Missing ps_token' });
}
const installation = installations.get(myAddonId);
if (!installation) {
return res.status(403).json({ error: 'Unknown installation' });
}
// Verify signature and expiry using the stored psSigningSecret
try {
jwt.verify(psConfig, installation.psSigningSecret);
} catch {
return res.status(401).json({ error: 'Invalid or expired ps_token' });
}
// Determine your actual status (query your DB, check job queue, etc.)
const isHealthy = checkHealth(myAddonId);
res.json({
status: isHealthy ? 'ok' : 'error',
lastSuccessAt: isHealthy ? new Date().toISOString() : null,
lastFailureAt: isHealthy ? null : new Date().toISOString(),
statusMessage: isHealthy
? { en: 'Running normally', cs: 'Běží normálně' }
: { en: 'Sync failed — check credentials', cs: 'Synchronizace selhala — zkontrolujte přihlašovací údaje' },
});
});
function checkHealth(_myAddonId: string): boolean {
// Your actual health check logic here
return true;
}
---
tab: PHP
// status.php
// Requires firebase/php-jwt: composer require firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$psConfig = $_GET['ps_token'] ?? null;
$myAddonId = $_GET['myAddon_id'] ?? null;
if (!$psConfig || !$myAddonId) {
http_response_code(401);
echo json_encode(['error' => 'Missing ps_token']);
exit;
}
$installationFile = __DIR__ . "/installations/$myAddonId.json";
if (!file_exists($installationFile)) {
http_response_code(403);
echo json_encode(['error' => 'Unknown installation']);
exit;
}
$installation = json_decode(file_get_contents($installationFile), true);
$psSigningSecret = $installation['psSigningSecret'] ?? null;
// Verify signature and expiry
try {
JWT::decode($psConfig, new Key($psSigningSecret, 'HS256'));
} catch (\Exception $e) {
http_response_code(401);
echo json_encode(['error' => 'Invalid or expired ps_token: ' . $e->getMessage()]);
exit;
}
// Return status
header('Content-Type: application/json');
echo json_encode([
'status' => 'ok',
'lastSuccessAt' => date('c'),
'lastFailureAt' => null,
'statusMessage' => [
'en' => 'Running normally',
'cs' => 'Běží normálně',
],
]);
URLs List
In addition to configUrl, you can define an array of extra links (urls) that appear in the addon detail page sidebar. These are useful for deep-links into your service, such as log viewers, reports, or dashboards.
{
"urls": [
{
"sortOrder": 1,
"name": {
"en": "Sync Logs",
"cs": "Logy synchronizace"
},
"icon": "list",
"url": "https://your-addon.example.com/logs?addon=${myAddon.id}",
"urlType": "iframe"
},
{
"sortOrder": 2,
"name": {
"en": "Open Dashboard",
"cs": "Otevřít přehled"
},
"icon": "external-link",
"url": "https://your-addon.example.com/dashboard",
"urlType": "link"
}
]
}
Fields:
| Field | Type | Description |
|---|---|---|
sortOrder | number | Display order in the sidebar (ascending) |
name | TranslatableString | Button label — object with language codes as keys |
icon | string | Icon name (Lucide icon identifier, e.g. list, history, settings) |
url | string | The destination URL. Supports template variables (see below). |
urlType | "link" | "iframe" | How the URL is opened (see below) |
URL template variables
The url field supports the following template variables, which are replaced server-side before the URL is sent to the browser:
| Variable | Replaced with |
|---|---|
${HOSTNAME} | The platform’s public base URL (e.g. https://app.example.com) |
${myAddon.id} | The myAddon_id of the current installation |
The platform also appends the following query parameters to each URL before opening it:
| Parameter | Description |
|---|---|
ps_token | Short-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform. |
myAddon_id | ID of the addon installation. |
organisation_id | ID of the organisation. |
tenant_id | ID of the tenant. |
user_id | ID of the user who triggered the action. |
language | Current UI language of the user (e.g. en, cs). |
The `ps_token` JWT contains two claims: `sub` (`myAddon_id`) and `org` (`organisation_id`). The plain query params are provided for convenience — always verify the JWT signature before trusting any of them.
urlType: "link" vs urlType: "iframe"
| Type | Behaviour |
|---|---|
link | Clicking the button opens the URL in a new browser tab. The user navigates away from {productName}. |
iframe | Clicking the button opens the URL inline as an embedded panel inside the addon detail page. The user stays in context. |
Use iframe for views that are meant to be consumed without leaving the platform (e.g. an invocation log or a status dashboard). Use link when the destination is a full-featured external application that does not make sense to embed (e.g. your admin panel).
TranslatableString
Several fields (name, shortDescription, etc.) support multiple languages. Provide a JSON object with IETF language tags as keys:
{
"en": "Sync Logs",
"cs": "Logy synchronizace",
"de": "Synchronisierungsprotokolle",
"sk": "Logy synchronizácie",
"pl": "Dzienniki synchronizacji"
}
The platform selects the string matching the user’s language, falling back to en if the requested language is not available.
Supported languages: en, cs, de, sk, pl, hu, fr, it, es, ro, hr, ua, pt, sl
Response Format
All lifecycle endpoints (activationUrl, deactivationUrl, pauseUnpauseUrl) must return JSON with at minimum a success boolean:
{ "success": true }
On failure, include an error message:
{ "success": false, "error": "Failed to provision workspace: quota exceeded" }
Return an appropriate HTTP status code:
200— success400— bad request (invalid or missing payload fields)500— unexpected server error
The platform treats any non-2xx HTTP status or { "success": false } response as a failure. For activationUrl this causes the installation to fail and roll back. For deactivationUrl the error is logged but the uninstall still completes. For pauseUnpauseUrl the error is logged and the status update does not take effect.
Debugging with data
Any JSON response from a lifecycle endpoint (activationUrl, deactivationUrl, pauseUnpauseUrl, statusUrl) may include an optional data field. The platform forwards this value unchanged to the frontend response, making it visible in the browser’s developer tools (Network tab). This is intentional and is a convenient way to surface internal state or diagnostic information without requiring a separate debug endpoint.
// Example: activation response with debug data
{
"success": false,
"error": "Quota exceeded",
"data": {
"currentUsage": 42,
"limit": 40,
"resetAt": "2026-03-01T00:00:00Z"
}
}
The data field is never displayed in the {productName} UI — it is only present in raw API responses. Do not put sensitive user data here; treat it as a developer-only diagnostic channel.