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.
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 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: trueis 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 yourstatusUrlalready 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
phaseand the remaining time under the status. - While the addon detail is open, the platform asks your
statusUrlagain every 4 seconds for as long as the answer contains aprogress. Keep the status call cheap, and sendprogress: null(or leave it out) as soon as the job ends — the bar disappears and the polling slows down. - Make
percentonly grow while a job runs; a bar that jumps back looks like a failure. - When the job ends, report its outcome in
status,lastSuccessAt,lastFailureAtandstatusMessage.
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:
- The platform asks your
statusUrlagain and looks the action up byid. Only an action that your status advertises right now and that is notdisabledcan 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”. - If the action has
confirm, the user confirms first. - The platform sends a
POSTwith the JSON body{"myAddon_id": "...", "action": "<id>"}to the action’surl. 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 verifyps_tokenthe same way. Theurlpoints to your service and may contain the${myAddon.id}template variable. - Any
2xxanswer 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 throughprogress. - The page reads your status again, so a button that started a job turns
disabledand 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.
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
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— 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.