ÚvodPlatformaCeníkOtázkyNápovědaKontakt Kivea FlexiKivea PohodaKivea účetnictvíKivea fakturace Vyzkoušet zdarma

What is this?

Admin note: This is the developer guide for creating “Addons”—custom extensions that add new features or integrations to your e-shop’s marketplace. Think of it as the manual for building your own “apps” for the platform.

Who is this for? Administrators should forward this document to their development team or an external agency. It is highly technical and contains the instructions needed to build, connect, and manage a custom addon that works seamlessly within your e-shop.

Key benefits for the e-shop:

  • Tailored Functionality: Build exactly the features your business needs if they aren’t already available in the standard marketplace.
  • Seamless Integration: Custom addons look and feel like part of the platform, providing a consistent experience for your staff.
  • Secure Connections: The system handles all the complex security (like “tokens”) automatically, so your custom tools can safely access your data.
  • Lifecycle Management: Easily install, pause, or remove your custom tools just like any other app in the marketplace.

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.


Addon versions

A marketplace addon has two levels. The addon itself is only its identity: code, name, category, author, status and a pointer to its current version. Everything that describes what the addon is and how the platform treats it lives on the addon version: texts, images, price, all the URLs above, and the settings below.

To publish a change you create a new version and make it the current one. The platform always reads the current version — the lifecycle URLs, the status and configuration URLs, and the settings in the table — so a new version can change them (for example start requiring a flavour) without touching the earlier versions.

Field (on the version) Meaning
tag Badge shown on the addon card in the marketplace: new, recommended or priceOff.
singleInstance When true, the addon can be installed only once per organisation. See Flavours and single installation.
requireFlavours List of flavours an organisation must already have to see and install the addon.
providesFlavours List of flavours added to the organisation when the addon is activated.
`tag`, `singleInstance`, `requireFlavours` and `providesFlavours` used to be fields of the addon itself. They now belong to the version; the values that existed were copied to every version of the addon. When you read or edit them, always use the version.

Flavours and single installation

Flavours are feature labels of an organisation (for example replica or flexibee) that switch on parts of the product. An addon can depend on them and extend them. Both fields are JSON arrays of flavour keys on the addon version:

{
    "singleInstance": true,
    "requireFlavours": ["replica"],
    "providesFlavours": ["flexibee"]
}
Setting What happens
requireFlavours The addon is hidden from the marketplace list of an organisation that lacks any of these flavours. If the user opens its detail directly, a notice explains what is missing and Activate is disabled. The requirement is checked again on activation: if it is not met, the installation ends in the failed state and the user gets an error.
providesFlavours After a successful activation (with or without an activationUrl) the missing flavours are added to the organisation.
singleInstance One installation per organisation. Installations in the state not-installed, installed or paused block a new one; failed and uninstalled do not. The detail page disables Activate, and activation is refused on the server too.

When the addon is uninstalled, only the flavours that this installation added are removed — never flavours the organisation had before. A flavour also stays when another installed or paused addon of the same organisation provides it.

Use `requireFlavours` for an addon that only makes sense next to another feature, and `providesFlavours` for an addon that turns a feature on. An addon that needs one installation per external system (for example one per database) should leave `singleInstance` off.

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.

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 a dialog inside the {productName} UI. The user stays in context. Suitable for lightweight settings forms. Your page can close the dialog itself, see Closing the dialog.
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>

Closing the configuration dialog

With configUrlType: "iframe" your page can close the dialog itself, typically right after it saved the configuration. Post a message to the parent window:

// after your "save" request succeeded
window.parent.postMessage({ type: 'productsync:addon:close-config', saved: true }, '*');
  • saved: true is optional. When it is set, {productName} shows “Configuration saved” after closing — a message shown inside your page would disappear together with the dialog.
  • Send it only after a successful save. When saving fails, do not send anything: the dialog stays open so the user can read the error and fix the form.
  • The message is honoured only when it comes from the configuration iframe itself, whatever its origin, so nothing else can close the dialog this way.
  • However the dialog is closed — by your message, by the cross or by Esc — the addon detail page is reloaded: the status, progress and actions are read again from your statusUrl. The user sees the effect of the new configuration immediately, so make sure your statusUrl already reflects what you just saved.

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). The same answer can also carry the progress of a long-running job and action buttons (for example “Synchronize now”), see below.

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' | 'unknown';

    /** 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>;

    /**
     * Optional. Progress of a job that is running right now; leave it out (or null) when nothing runs.
     * Shown as a progress bar under the status.
     */
    progress?: {
        /** Short text of what is happening, in the language of the `language` parameter (e.g. "Reading data"). */
        phase: string | null;
        /** 0-100. Values outside the range are clamped. */
        percent: number;
        /** Estimated seconds left, or null when unknown. */
        etaSeconds: number | null;
    } | null;

    /** Optional. Buttons shown under the status, see "Actions". */
    actions?: AddonAction[];
}

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
unknown Grey The state cannot be determined, typically because the addon is not configured yet. Add a statusMessage that says what to do (e.g. “Configure the connection first”).
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ě',
    ],
]);

Progress of a long-running job

When your addon runs something that takes a while (a synchronization, an export, a migration), report it in progress while it runs:

{
    "status": "ok",
    "lastSuccessAt": "2026-10-08T12:00:00Z",
    "lastFailureAt": null,
    "statusMessage": null,
    "progress": { "phase": "Reading data", "percent": 42, "etaSeconds": 180 }
}
  • The platform draws a bar with the percentage, the phase and the remaining time under the status.
  • While the addon detail is open, the platform asks your statusUrl again every 4 seconds for as long as the answer contains a progress. Keep the status call cheap, and send progress: null (or leave it out) as soon as the job ends — the bar disappears and the polling slows down.
  • Make percent only grow while a job runs; a bar that jumps back looks like a failure.
  • When the job ends, report its outcome in status, lastSuccessAt, lastFailureAt and statusMessage.

Actions

An addon can offer buttons on its detail page, such as Synchronize now or Re-run full synchronization. They are part of the status answer, so your addon needs no screen of its own. Add them to actions:

interface AddonAction {
    /** Unique within your addon. */
    id: string;

    /** Text of the button: a string or a language -> text map (see TranslatableString). */
    label: string | Record<string, string>;

    /** Optional icon name, e.g. 'play' or 'refresh-cw'. Icons the platform does not ship are not drawn. */
    icon?: string;

    /** Optional: 'default' (primary), 'outline' or 'destructive'. */
    variant?: 'default' | 'outline' | 'destructive';

    /** Shown but not clickable, e.g. while a run is already in progress. Also refused by the server. */
    disabled?: boolean;

    /** When set, the user has to confirm first. All texts may be strings or language maps. */
    confirm?: { title: string | Record<string, string>; text?: string | Record<string, string>; confirmLabel?: string | Record<string, string> };

    /** HTTPS URL the platform POSTs to when the button is clicked. Never sent to the browser. */
    url: string;
}
{
    "status": "ok",
    "lastSuccessAt": "2026-10-08T12:00:00Z",
    "lastFailureAt": null,
    "statusMessage": null,
    "actions": [
        { "id": "sync", "label": { "en": "Synchronize now", "cs": "Synchronizovat teď" }, "icon": "play", "url": "https://addon.example.com/addon/actions/sync" },
        {
            "id": "full-resync", "label": { "en": "Re-run full synchronization", "cs": "Spustit úplnou synchronizaci znovu" }, "icon": "refresh-cw", "variant": "outline",
            "url": "https://addon.example.com/addon/actions/full-resync",
            "confirm": { "title": { "en": "Re-run everything?", "cs": "Spustit vše znovu?" }, "text": { "en": "This can take a long time.", "cs": "Může to trvat dlouho." } }
        }
    ]
}

What happens when the user clicks a button:

  1. The platform asks your statusUrl again and looks the action up by id. Only an action that your status advertises right now and that is not disabled can run — the browser never names a URL or a command. A missing action answers “Unknown action”, a disabled one “This action is not available now”.
  2. If the action has confirm, the user confirms first.
  3. The platform sends a POST with the JSON body {"myAddon_id": "...", "action": "<id>"} to the action’s url. The URL is signed exactly like the status call: the same query parameters (ps_token, myAddon_id, organisation_id, tenant_id, user_id, language) are appended, and you verify ps_token the same way. The url points to your service and may contain the ${myAddon.id} template variable.
  4. Any 2xx answer is success. Anything else is shown to the user as an error (“The addon answered HTTP 500”). The platform waits at most 30 seconds for the answer, so start long work in the background, answer at once and report the running job through progress.
  5. The page reads your status again, so a button that started a job turns disabled and the progress bar appears.
The platform does not check the user's role for an external action: every user who can open the addon detail can click it. The call carries `user_id`; decide in your endpoint who may do what, and do not expose destructive actions without a `confirm`.
tab: TypeScript
// the status answer advertises the buttons; a running job disables them
app.get('/addon/status', (req: Request, res: Response) => {
    // ... verify ps_token as above ...
    const running = isJobRunning(req.query.myAddon_id as string);
    res.json({
        status: 'ok',
        lastSuccessAt: lastSuccess(),
        lastFailureAt: null,
        statusMessage: null,
        progress: running ? { phase: 'Reading data', percent: jobPercent(), etaSeconds: null } : null,
        actions: [
            { id: 'sync', label: { en: 'Synchronize now', cs: 'Synchronizovat teď' }, icon: 'play', disabled: running, url: `${PUBLIC_URL}/addon/actions/sync` },
        ],
    });
});

// the platform POSTs here when the user clicks the button
app.post('/addon/actions/:id', express.json(), (req: Request, res: Response) => {
    // ... verify ps_token as above (same query parameters as the status call) ...
    if (req.params.id !== req.body.action) return res.status(400).json({ error: 'Action mismatch' });
    startJobInBackground(req.body.myAddon_id);   // do not wait for the job
    res.status(202).json({ started: true });     // any 2xx is success
});
---
tab: PHP
// status.php: the answer advertises the buttons
$running = isJobRunning($myAddonId);
echo json_encode([
    'status'        => 'ok',
    'lastSuccessAt' => lastSuccess(),
    'lastFailureAt' => null,
    'statusMessage' => null,
    'progress'      => $running ? ['phase' => 'Reading data', 'percent' => jobPercent(), 'etaSeconds' => null] : null,
    'actions'       => [[
        'id'       => 'sync',
        'label'    => ['en' => 'Synchronize now', 'cs' => 'Synchronizovat teď'],
        'icon'     => 'play',
        'disabled' => $running,
        'url'      => PUBLIC_URL . '/addon/actions/sync.php',
    ]],
]);

// actions/sync.php: the platform POSTs here when the user clicks the button
// ... verify ps_token as above (same query parameters as the status call) ...
$body = json_decode(file_get_contents('php://input'), true);
startJobInBackground($body['myAddon_id']);   // do not wait for the job
http_response_code(202);                     // any 2xx is success
echo json_encode(['started' => true]);

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.
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


Built-in addons

Addons that {productName} provides itself (for example the ABRA Flexi and Pohoda synchronizations) follow the same contract as the addons you build, so the user sees one consistent addon detail page. The difference is only where the work happens: instead of HTTPS addresses, their lifecycle fields (activationUrl, deactivationUrl, pauseUnpauseUrl, statusUrl, configUrl) contain the value builtin:, which means “do it inside the platform”. Their configuration page is a page of the platform shown in the same dialog, and it closes itself the same way as described in Closing the configuration dialog.

You never use builtin: in your own addon. It matters to you as a reference: whatever a built-in addon shows — a status, a progress bar, Start and Re-run buttons, a Pause — your addon can show through statusUrl, progress, actions and pauseUnpauseUrl.


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.