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.

FieldWhen calledMethod
activationUrlUser installs the addonPOST
deactivationUrlUser uninstalls the addonPOST
pauseUnpauseUrlUser pauses or resumes the addonPOST
configUrlUser clicks “Configure”Opened in browser (iframe or link)
statusUrlPlatform polls for current addon statusGET

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.

TypeBehaviour
linkThe URL opens in a new browser tab. The user leaves the {productName} UI. Suitable for complex multi-page configuration wizards.
iframeThe 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:

ParameterDescription
ps_tokenShort-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform.
myAddon_idID of the addon installation.
organisation_idID of the organisation.
tenant_idID of the tenant.
user_idID of the user who triggered the action.
languageCurrent 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:

ParameterDescription
ps_tokenShort-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform.
myAddon_idID of the addon installation.
organisation_idID of the organisation.
tenant_idID of the tenant.
user_idID of the user who triggered the action.
languageCurrent 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:

StatusColourMeaning
okGreenThe addon is running normally
pausedYellowThe addon is paused by the user
blockedOrangeProcessing is blocked (e.g. rate limit, upstream issue)
invalid-authRedAuthentication to an external service failed
errorRedAn 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:

FieldTypeDescription
sortOrdernumberDisplay order in the sidebar (ascending)
nameTranslatableStringButton label — object with language codes as keys
iconstringIcon name (Lucide icon identifier, e.g. list, history, settings)
urlstringThe 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:

VariableReplaced 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:

ParameterDescription
ps_tokenShort-lived JWT (10 min) signed with the psSigningSecret from activation. Use this to verify the request came from the platform.
myAddon_idID of the addon installation.
organisation_idID of the organisation.
tenant_idID of the tenant.
user_idID of the user who triggered the action.
languageCurrent 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.
TypeBehaviour
linkClicking the button opens the URL in a new browser tab. The user navigates away from {productName}.
iframeClicking 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 — success
  • 400 — 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.