# Add-on Definitions

https://dev.wisecp.com/es/addon-definitions

The eleven endpoints that manage add-on definitions, their options, icons and categories.

## Overview

An add-on definition is the **template** for something sold alongside a product: extra disk, automated backups, an extra licence. An add-on already attached to a client's service is a different thing; here you manage the definition in the catalogue.

A definition holds **options**, and that is where the price lives. Options sit under a language because their names are translated, while the price is kept per currency.

The last four endpoints run the add-on categories. A category does nothing on its own; it organises the add-on catalogue.

## Reference

### Listing the Add-on Definitions

get/api/v1/admin/products/addons

`Products/GetProductAddons` admin paged

Returns the add-on definitions in the catalogue.

Query parameters 5

groupstringFilters by the main group key.

categoryintFilters by add-on category.

searchstringSearches the add-on name.

pageintDefaults to 1.

limitintDefaults to 25, maximum 100.

Response fields data[] — 9

idintAdd-on id.

namestringThe add-on name in the current language.

descriptionstringThe description.

groupstringThe main group key.

categoryintId of the add-on category.

statusstring`active` or `inactive`.

rankintThe display order.

icon_typestring`font` or `image`.

iconstringThe icon class.

Meta 4

totalintTotal records matching the filter.

pageintThe page you are on.

limitintThe page size.

next_pageintThe next page. Zero means you are on the last one.

Errors 1

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -G 'https://panel.example.com/api/v1/admin/products/addons' \
  -H "Authorization: Bearer $API_KEY" \
  -d group=hosting
```

```javascript
const url = new URL('https://panel.example.com/api/v1/admin/products/addons');
url.searchParams.set('group', 'hosting');

const res  = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const body = await res.json();
```

```php
$url = 'https://panel.example.com/api/v1/admin/products/addons?' . http_build_query(['group' => 'hosting']);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->GetProductAddons([], ['group' => 'hosting']);
```

### Add-on Detail

get/api/v1/admin/products/addons/{id}

`Products/GetProductAddon` admin options sit under language

Returns one add-on definition with its settings, rules and options in every language.

Response fields data — 15

idintAdd-on id.

groupstringThe main group key.

categoryintId of the add-on category.

statusstring`active` or `inactive`.

rankintThe display order.

override_user_currencyboolWhether it overrides the client's currency.

tax_exemptboolWhether it is tax exempt.

requirement_idsint[]Ids of the attached requirements.

product_linkobjectThe linked product as `{id, type}`.

icon_typestring`font` or `image`.

iconstringThe icon class or the name of the uploaded image.

list_templateintId of the list template.

typestringThe input the client sees: `select`, `quantity`, `checkbox` or `radio`.

propertiesobjectThe rules for the input type: whether it is compulsory, multiple purchases, minimum and maximum quantity, the step, and tying the price to the product period.

langsobject 3 fieldsA map from language code to a content object.

namestringThe add-on name.

descriptionstringThe description.

optionsobject[] 9 fieldsThe options in this language.

idintOption id.

namestringThe option's name in that language.

periodstring`day`, `month` or `year`.

period_timeintThe period multiplier. Three months is period month, multiplier three.

amountfloatThe price. Present in the single-currency shape.

cidintCurrency id. Present in the single-currency shape.

pricingobjectA map from currency id to an `{enabled, amount}` object. Present in the multi-currency shape.

moduleobjectA map from module name to configuration. What gets handed to the module when the option is picked.

hiddenboolWhether the option is hidden.

Errors 2

not_found404No such add-on.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl 'https://panel.example.com/api/v1/admin/products/addons/148' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res  = await fetch('https://panel.example.com/api/v1/admin/products/addons/148', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons/148');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->GetProductAddon(['id' => 148]);

// A price arrives in one of two shapes; handle both.
$option = $response['data']['langs']['en']['options'][0];
$price  = $option['pricing']['1']['amount'] ?? $option['amount'] ?? 0;
```

Response 200

```json
{
  "data": {
    "id": 148,
    "group": "server",
    "category": 296,
    "status": "active",
    "type": "select",
    "properties": { "show_by_pp": 1, "multiple_purchases": 0 },
    "requirement_ids": [],
    "product_link": { "id": 0, "type": "" },
    "langs": {
      "en": {
        "name": "Automated Backups",
        "description": "Keeps daily backups.",
        "options": [
          {
            "id": 0,
            "name": "I want",
            "period": "month",
            "period_time": 1,
            "amount": 2,
            "cid": 5,
            "module": {
              "HetznerCloud": { "configurable": { "backup": 1 } }
            }
          }
        ]
      }
    }
  }
}
```

### Creating an Add-on Definition

post/api/v1/admin/products/addons

`Products/CreateProductAddon` admin 201

Opens a new add-on definition in the catalogue.

Body 21

groupstringrequiredThe main group key. It comes from the groups lookup.

categoryintrequiredId of the add-on category.

nameobjectrequiredA map from language code to name. The current language needs one.

descriptionobjectA map from language code to description.

statusstring`active` or `inactive`.

rankintThe display order.

typestringThe input shape: `select`, `checkbox`, `radio` or `quantity`.

compulsoryboolMakes the add-on compulsory.

min_quantityintThe minimum quantity. Meaningful on the quantity input.

max_quantityintThe maximum quantity.

stepintThe quantity step.

multiple_purchasesboolLets the same add-on be bought more than once.

show_by_product_periodboolShows the price against the product's period.

override_user_currencyboolOverrides the client's currency.

tax_exemptboolMakes it tax exempt.

requirement_idsint[]Ids of the requirements to attach.

product_linkintId of the product to link.

icon_typestring`font` or `image`.

iconstringThe icon class.

list_templateintId of the list template.

optionsobjectA map from option key to an option object. Each option carries a name per language, a price per currency, a period, module configuration and whether it is hidden. Sending it replaces the whole set.

Response fields data

dataobjectThe add-on created, returned with `201`. Same shape as the detail endpoint.

Errors 5

group_required422`group` was empty.

category_required422`category` was empty.

name_required422There is no name in the current language.

create_failed422Creation was refused.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X POST 'https://panel.example.com/api/v1/admin/products/addons' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"group":"hosting","category":12,"type":"select","name":{"en":"Extra Disk"},"options":{"o1":{"name":{"en":"10 GB"},"pricing":{"1":{"enabled":true,"amount":5}},"cycle":"monthly"}}}'
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addons', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    group: 'hosting',
    category: 12,
    type: 'select',
    name: { en: 'Extra Disk' },
    options: {
      o1: {
        name: { en: '10 GB' },
        pricing: { 1: { enabled: true, amount: 5 } },
        cycle: 'monthly',
      },
    },
  }),
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'group'    => 'hosting',
        'category' => 12,
        'type'     => 'select',
        'name'     => ['en' => 'Extra Disk'],
        'options'  => [
            'o1' => [
                'name'    => ['en' => '10 GB'],
                'pricing' => [1 => ['enabled' => true, 'amount' => 5]],
                'cycle'   => 'monthly',
            ],
        ],
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->CreateProductAddon([
    'group'    => 'hosting',
    'category' => 12,
    'type'     => 'select',
    'name'     => ['en' => 'Extra Disk'],
    'options'  => [
        'o1' => [
            'name'    => ['en' => '10 GB'],
            'pricing' => [1 => ['enabled' => true, 'amount' => 5]],
            'cycle'   => 'monthly',
        ],
    ],
]);
```

### Updating an Add-on Definition

patch/api/v1/admin/products/addons/{id}

`Products/UpdateProductAddon` admin options are all-or-nothing

Applies the fields you send. If you send the options, the set is replaced as a whole. Every body field is optional.

Body 21

groupstringThe main group key. It comes from the groups lookup.

categoryintId of the add-on category.

nameobjectA map from language code to name. Only the languages you send change.

descriptionobjectA map from language code to description.

statusstring`active` or `inactive`.

rankintThe display order.

typestringThe input shape: `select`, `checkbox`, `radio` or `quantity`.

compulsoryboolMakes the add-on compulsory.

min_quantityintThe minimum quantity. Meaningful on the quantity input.

max_quantityintThe maximum quantity.

stepintThe quantity step.

multiple_purchasesboolLets the same add-on be bought more than once.

show_by_product_periodboolShows the price against the product's period.

override_user_currencyboolOverrides the client's currency.

tax_exemptboolMakes it tax exempt.

requirement_idsint[]Ids of the requirements to attach.

product_linkintId of the product to link.

icon_typestring`font` or `image`.

iconstringThe icon class.

list_templateintId of the list template.

optionsobjectA map from option key to an option object. Each option carries a name per language, a price per currency, a period, module configuration and whether it is hidden. Sending it replaces the whole set.

Response fields data

dataobjectThe add-on as it now stands. Same shape as the detail endpoint.

Errors 2

not_found404No such add-on.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X PATCH 'https://panel.example.com/api/v1/admin/products/addons/131' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"status":"inactive"}'
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addons/131', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ status: 'inactive' }),
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons/131');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['status' => 'inactive']),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
// Even to change one option's price, send the WHOLE set.
$addon   = Api::Products()->GetProductAddon(['id' => 131])['data'];
$options = $addon['langs']['en']['options'];

// ... change $options ...

Api::Products()->UpdateProductAddon([
    'id'      => 131,
    'options' => $options,
]);
```

### Deleting an Add-on Definition

delete/api/v1/admin/products/addons/{id}

`Products/DeleteProductAddon` admin cannot be undone

Deletes the add-on definition. Its language records go too.

Response fields data — 2

deletedboolWhether the delete succeeded.

idintId of the deleted add-on.

Errors 3

not_found404No such add-on.

blocked_by_gate422The `gate:product.addon_delete` hook vetoed the operation.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X DELETE 'https://panel.example.com/api/v1/admin/products/addons/131' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addons/131', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons/131');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->DeleteProductAddon(['id' => 131]);
```

### Uploading an Add-on Icon

post/api/v1/admin/products/addons/{id}/icon

`Products/UploadAddonIcon` admin SVG

Uploads the add-on icon. Image files and SVG are accepted.

Body 1

imagestringrequiredThe icon image. A base64 data URI or a link that can be fetched.

Response fields data — 1

urlstringThe icon's public address.

Errors 5

not_found404No such add-on.

file_required422The file field was empty.

file_invalid422The file could not be read or its type was refused.

file_failed422The file could not be stored.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X POST 'https://panel.example.com/api/v1/admin/products/addons/131/icon' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"image":"data:image/png;base64,iVBORw0KGgo..."}'
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addons/131/icon', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ image: 'data:image/png;base64,iVBORw0KGgo...' }),
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons/131/icon');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'image' => 'data:image/svg+xml;base64,' . base64_encode($svg),
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->UploadAddonIcon([
    'id'    => 131,
    'image' => 'data:image/svg+xml;base64,' . base64_encode($svg),
]);
```

### Deleting an Add-on Icon

delete/api/v1/admin/products/addons/{id}/icon

`Products/DeleteProductAddonIcon` admin

Removes the uploaded icon image.

Response fields data — 2

deletedboolWhether the delete succeeded.

idintAdd-on id.

Errors 2

not_found404No such add-on.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X DELETE 'https://panel.example.com/api/v1/admin/products/addons/131/icon' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addons/131/icon', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addons/131/icon');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->DeleteProductAddonIcon(['id' => 131]);
```

### Listing the Categories

get/api/v1/admin/products/addon-categories

`Products/GetAddonCategories` admin

Returns the add-on categories.

Response fields data[] — 4

idintCategory id.

titlestringThe category title.

parent_idintId of the parent category. Zero means top level.

rankintThe display order.

Errors 1

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl 'https://panel.example.com/api/v1/admin/products/addon-categories' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res  = await fetch('https://panel.example.com/api/v1/admin/products/addon-categories', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addon-categories');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->GetAddonCategories();
```

### Creating a Category

post/api/v1/admin/products/addon-categories

`Products/CreateAddonCategory` admin 201

Opens an add-on category.

Body 3

titlestringrequiredThe category title.

parent_idintId of the parent category.

rankintThe display order.

Response fields data — 2

idintId of the new category.

titlestringThe title, after HTML stripping. Two fields come back, so read the list endpoint for the parent and the order.

Errors 4

title_required422`title` was empty.

blocked_by_gate422The `gate:product.category_save` hook vetoed the operation.

create_failed422The insert failed.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X POST 'https://panel.example.com/api/v1/admin/products/addon-categories' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Resources","rank":1}'
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addon-categories', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ title: 'Resources', rank: 1 }),
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addon-categories');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['title' => 'Resources', 'rank' => 1]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$category = Api::Products()->CreateAddonCategory(['title' => 'Resources']);

Api::Products()->CreateProductAddon([
    'group'    => 'hosting',
    'category' => $category['data']['id'],
    'name'     => ['en' => 'Extra Disk'],
]);
```

### Updating a Category

patch/api/v1/admin/products/addon-categories/{id}

`Products/UpdateAddonCategory` admin

Changes the category's title, parent or order.

Body 3

titlestringThe category title. If you send it, it cannot be empty.

parent_idintId of the parent category.

rankintThe display order.

Response fields data — 4

idintCategory id.

titlestringThe category title.

parentintId of the parent category. Zero means top level. The list endpoint names this one `parent_id`.

rankintThe display order. The stored row comes back whole, so a few internal columns ride along.

Errors 4

not_found404No such category.

title_required422A title was sent but it was empty.

no_changes422The body carries no editable field.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X PATCH 'https://panel.example.com/api/v1/admin/products/addon-categories/12' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"title":"System Resources","rank":2}'
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addon-categories/12', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ title: 'System Resources', rank: 2 }),
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addon-categories/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['title' => 'System Resources', 'rank' => 2]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
// An empty body comes back as 'no_changes' - skip the call when nothing changed.
$response = Api::Products()->UpdateAddonCategory([
    'id'    => 12,
    'title' => 'System Resources',
]);
```

### Deleting a Category

delete/api/v1/admin/products/addon-categories/{id}

`Products/DeleteAddonCategory` admin

Deletes the add-on category.

Response fields data — 2

deletedboolWhether the delete succeeded.

idintId of the deleted category.

Errors 2

not_found404No such category.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X DELETE 'https://panel.example.com/api/v1/admin/products/addon-categories/12' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/products/addon-categories/12', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
```

```php
$ch = curl_init('https://panel.example.com/api/v1/admin/products/addon-categories/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Products()->DeleteAddonCategory(['id' => 12]);
```

## Pitfalls

> **Sending options rewrites the whole set**
> 
> If `options` is in the update body, every existing option is dropped **in every language** and replaced by the set you sent. To change one option's price, read the detail first and send the set back whole. Leaving the field out keeps the options.

> **A price comes back in two different shapes**
> 
> An option carries either a flat amount with a currency id, or a map of `{enabled, amount}` objects per currency. Which one you get depends on how the record was written, so reading code has to handle both.

> **Options are stored per language**
> 
> Options live inside the language object, so each language holds its own copy. Adding an option to one language and not another leaves it invisible to clients using the other one.

> **An empty update body is an error**
> 
> The category update refuses a body carrying no editable field and returns `no_changes`. When nothing changed, do not send the request at all.

## Related Articles

- [Product Endpoints](https://dev.wisecp.com/en/product-endpoints)
- [Product Requirements](https://dev.wisecp.com/en/product-requirements)
