Product Endpoints

7 views Markdown

The nine endpoints that list the product catalogue and create, read, update and delete a product.

Overview

These endpoints run the product itself in the catalogue: listing, creating, reading, updating and deleting. The three lookups beside them tell you which values are valid when creating.

There is a deliberate split between creating and updating: creation only puts up the skeleton, and everything that makes a product sellable goes in through the update endpoint. The panel follows the same order.

Reference

Listing the Products

get/api/v1/admin/products
Products/GetProducts admin paged

Returns the product catalogue. With no filter you get every type, newest first.

Query parameters 7
searchstringSearches the product title.
typestringFilters by type: hosting, server, software, ssl or special.
group_idintSpecial group id. Using it requires type to be special.
categoryintFilters by category id.
statusstringactive or inactive.
pageintDefaults to 1.
limitintDefaults to 25, maximum 100.
Response fields data[] — 8
idintProduct id.
titlestringThe product title in the current language.
typestringThe product's behaviour type.
modulestringThe module attached. none when there is none.
statusstringactive or inactive.
categoryobjectThe category as {id, title}. On an uncategorised product the title is empty.
service_countintHow many services were created from this product.
created_atstringWhen it was created.
Meta 4
totalintTotal records matching the filter.
pageintThe page you are on.
limitintThe page size.
next_pageintThe next page. 0 means you are on the last one.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl -G 'https://panel.example.com/api/v1/admin/products' \
  -H "Authorization: Bearer $API_KEY" \
  -d type=hosting \
  -d limit=50
const url = new URL('https://panel.example.com/api/v1/admin/products');
url.searchParams.set('type', 'hosting');
url.searchParams.set('limit', '50');

const res  = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const body = await res.json();
$url = 'https://panel.example.com/api/v1/admin/products?' . http_build_query(['type' => 'hosting', 'limit' => 50]);

$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);
$page = 1;
$all  = [];

do {
    $response = Api::Products()->GetProducts([], [
        'type'  => 'hosting',
        'page'  => $page,
        'limit' => 100,
    ]);

    $all  = array_merge($all, $response['data']);
    $page = $response['meta']['next_page'];
} while ($page > 0);
Response
{
  "data": [
    {
      "id": 15,
      "title": "Starter SSD 1",
      "type": "hosting",
      "module": "cPanel",
      "status": "active",
      "category": { "id": 439, "title": "Economy Web Hosting" },
      "service_count": 12,
      "created_at": "2026-01-01 10:00:00"
    }
  ],
  "meta": { "total": 499, "page": 1, "limit": 25, "next_page": 2 }
}

Creating a Product

post/api/v1/admin/products
Products/CreateProduct admin 201

Opens a skeleton product. Prices, limits and language content go in later with the update endpoint.

Body 6
typestringrequiredThe product type: hosting, server, software or special. ssl cannot stand alone; it lives inside a special group.
namestringrequiredThe product title. The same text is written to every language.
group_idintSpecial group id. Required when the type is special.
categoryintCategory id.
modulestringName of the module to attach. Giving one turns automatic provisioning on.
hiddenboolHides the product from the storefront.
Response fields data
dataobjectThe product created, returned with 201. Same shape as the detail endpoint.
Errors 5
invalid_type422The type is not one of the allowed values.
name_required422name was empty.
group_required422The type is special but no group was given.
blocked_by_gate422The gate:product.create hook vetoed the operation.
create_failed422Creation was refused. The group may be missing or the route may collide.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/products' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type":"hosting","name":"Starter SSD 1","category":439,"module":"cPanel"}'
const res = await fetch('https://panel.example.com/api/v1/admin/products', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: 'hosting',
    name: 'Starter SSD 1',
    category: 439,
    module: 'cPanel',
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'type'     => 'hosting',
        'name'     => 'Starter SSD 1',
        'category' => 439,
        'module'   => 'cPanel',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Creation leaves a skeleton; follow it with an update for a usable product.
$created = Api::Products()->CreateProduct([
    'type'     => 'hosting',
    'name'     => 'Starter SSD 1',
    'category' => 439,
    'module'   => 'cPanel',
]);

Api::Products()->UpdateProduct([
    'id'     => $created['data']['id'],
    'limits' => ['disk' => 100, 'bandwidth' => 'unlimited'],
]);

Product Detail

get/api/v1/admin/products/{id}
Products/GetProduct admin varies by module

Returns all of the product's settings, its relations and its content in every language.

Response fields data — 26
idintProduct id.
typestringThe product's behaviour type.
group_typestringThe group type key.
group_idintSpecial group id. 0 when it is not in one.
categoryobjectThe category as {id, name}.
statusstringactive or inactive.
visibilitystringvisible or invisible.
modulestringName of the attached module.
module_dataobjectThe module's own configuration. The fields depend entirely on the module; there is no fixed schema.
optionsobjectThe product options. The fields change with the type and the module.
additional_taxobjectAdditional tax as {enabled, items}.
tax_exemptboolWhether it is tax exempt.
override_user_currencyboolWhether it overrides the client's currency.
upgrade_enabledboolWhether upgrading is open.
affiliate_disabledboolWhether affiliate is switched off.
affiliate_ratefloatThe affiliate rate.
stockint | nullThe stock left. null means unlimited.
rankintThe display order.
upgradeable_product_idsint[]The products this one can be upgraded to.
addon_idsint[]Ids of the attached add-ons.
requirement_idsint[]Ids of the attached requirements.
notesstringAdmin notes. Never shown to the client.
prorateobjectDay-based proration as {enabled, days}.
recurring_cycles_limitobjectRenewal count limit as {enabled, value}.
auto_terminateobjectAutomatic termination as {enabled, days}.
langsobjectA map from language code to a content object.
titlestringThe product title.
descriptionstringA short description.
contentstringThe long content.
routestringThe URL slug.
featuresstringThe feature list.
seo_titlestringThe search title.
seo_keywordsstringThe search keywords.
seo_descriptionstringThe search description.
Errors 2
not_found404No such product.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/products/15' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/products/15', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/15');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Products()->GetProduct(['id' => 15]);

// The shape of module_data comes from the module - check the key exists.
$plan = $response['data']['module_data']['plan'] ?? null;

Updating a Product

patch/api/v1/admin/products/{id}
Products/UpdateProduct admin partial-safe

Applies the fields you send and leaves everything else as it was.

Body 22
statusstringactive or inactive.
categoryintCategory id.
hiddenboolHides it from the storefront.
notesstringAdmin notes.
rankintThe display order.
stockint | nullThe stock. Leaving it empty means unlimited.
subdomainsstringThe products allowed for sub-hosting.
affiliate_disabledboolSwitches affiliate off.
affiliate_ratefloatThe affiliate rate.
override_user_currencyboolOverrides the client's currency.
tax_exemptboolMakes it tax exempt.
additional_taxobjectThe additional tax configuration.
allow_qtyboolLets the buyer pick a quantity.
prorateobject{enabled, days}.
recurring_cycles_limitobject{enabled, value}.
auto_terminateobject{enabled, days}.
addon_idsint[]Ids of the add-ons to attach.
requirement_idsint[]Ids of the requirements to attach.
upgradeable_product_idsint[]The upgrade target products.
optionsobjectProduct options: popular, auto_approval, auto_install, seo_index, restrict_access, order_limit_per_user, free_domain, hide_domain, show_domain, hide_hosting, show_hosting, change_domain, renewal_selection_hide, download_link, demo_link, demo_admin_link, demo_admin_link, product_file_access, ctoc_service_transfer, server_group_id, server_id, activation_notification.
limitsobjectHosting resource limits: disk, bandwidth, email, database, addons, subdomain, ftp, park, max_email_per_hour. Each is a number or unlimited.
langsobjectA map from language code to a content object. Only the languages you send change.
titlestringThe product title.
descriptionstringA short description.
contentstringThe long content.
routestringThe URL slug.
featuresstringThe feature list.
seo_titlestringThe search title.
seo_keywordsstringThe search keywords.
seo_descriptionstringThe search description.
Response fields data
dataobjectThe product as it now stands. Same shape as the detail endpoint.
Errors 2
not_found404No such product.
insufficient_scope403The key lacks the required scope.
Request
curl -X PATCH 'https://panel.example.com/api/v1/admin/products/15' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"status":"inactive","limits":{"disk":100,"bandwidth":"unlimited"}}'
const res = await fetch('https://panel.example.com/api/v1/admin/products/15', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    status: 'inactive',
    limits: { disk: 100, bandwidth: 'unlimited' },
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/15');
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',
        'limits' => ['disk' => 100, 'bandwidth' => 'unlimited'],
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Changing the English text alone leaves the other languages untouched.
$response = Api::Products()->UpdateProduct([
    'id'    => 15,
    'langs' => [
        'en' => ['title' => 'Starter SSD 1', 'features' => 'NVMe storage'],
    ],
]);

Changing Status in Bulk

post/api/v1/admin/products/bulk
Products/BulkProducts admin status only

Changes several products' status in one call. Deleting is not available here.

Body 2
idsint[]requiredThe product ids. At least one.
actionstringrequiredactive or inactive.
Response fields data — 2
updatedint[]The ids whose status changed.
actionstringThe status that was applied.
Errors 2
ids_required422ids was empty.
invalid_action422The action is neither of the two values.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/products/bulk' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"ids":[15,16],"action":"inactive"}'
const res = await fetch('https://panel.example.com/api/v1/admin/products/bulk', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ ids: [15, 16], action: 'inactive' }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/bulk');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'ids'    => [15, 16],
        'action' => 'inactive',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Products()->BulkProducts([
    'ids'    => [15, 16],
    'action' => 'inactive',
]);

Deleting a Product

delete/api/v1/admin/products/{id}
Products/DeleteProduct admin cannot be undone

Deletes the product. Its language records and prices go with it.

Response fields data — 2
deletedboolWhether the delete succeeded.
idintId of the deleted product.
Errors 2
not_found404No such product.
blocked_by_gate422The gate:product.delete hook vetoed the operation.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/products/2030' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch('https://panel.example.com/api/v1/admin/products/2030', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/2030');
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);
$response = Api::Products()->DeleteProduct(['id' => 2030]);

Listing the Product Types

get/api/v1/admin/products/types
Products/GetProductTypes admin lookup

Returns the product types this installation offers. The valid values for creating come from here.

Response fields data[] — 4
keystringThe type key. This is what goes in the create body.
titlestringThe label to display.
descriptionstringWhat the type is.
iconstringThe icon class.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/products/types' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/products/types', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/types');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Products()->GetProductTypes();

Listing the Groups

get/api/v1/admin/products/groups
Products/GetProductGroups admin lookup

Returns the fixed and special catalogue groups. Each row hands you a ready type and group pair for creating.

Response fields data[] — 4
keystringThe group key. Special groups carry the id as a suffix.
titlestringThe label to display.
typestringThe product type this group maps to.
group_idintSpecial group id. 0 on a fixed group.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/products/groups' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/products/groups', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/products/groups');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A group row hands you both fields of the create body at once.
$group = Api::Products()->GetProductGroups()['data'][0];

Api::Products()->CreateProduct([
    'type'     => $group['type'],
    'group_id' => $group['group_id'],
    'name'     => 'New product',
]);

Listing the Categories

get/api/v1/admin/products/categories
Products/GetProductCategories admin lookup

Returns the category list flat. You build the tree yourself from the parent ids.

Query parameters 2
typestringThe group type. Defaults to hosting.
group_idintSpecial group id. Anything above zero makes the type special.
Response fields data[] — 4
idintCategory id.
parent_idintId of the parent category. 0 means top level.
titlestringThe category title.
routestringThe URL slug.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl -G 'https://panel.example.com/api/v1/admin/products/categories' \
  -H "Authorization: Bearer $API_KEY" \
  -d type=hosting
const url = new URL('https://panel.example.com/api/v1/admin/products/categories');
url.searchParams.set('type', 'hosting');

const res  = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const body = await res.json();
$url = 'https://panel.example.com/api/v1/admin/products/categories?' . http_build_query(['type' => '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);
$rows = Api::Products()->GetProductCategories([], ['type' => 'hosting'])['data'];

$byParent = [];
foreach ($rows as $row) $byParent[$row['parent_id']][] = $row;

Pitfalls

Creating does not give you a sellable product

A new product is born as a skeleton: a zero monthly price is seeded in the default currency and the title is copied into every language. Until the limits, the real price and the content go in, the product is not ready to sell. Follow the create with an update.

The update leaves some sections out

Cyclical pricing, metered pricing, module configuration and licence parameters cannot be edited here. They are rewritten with their current values, so leaving them out of your body does not wipe them. They are managed through their own endpoints.

Two fields have a module-dependent schema

The module_data and options fields on the detail change with the attached module and the product type; they have no fixed schema. Code that reaches straight into them breaks when the module changes, so check the key exists.

The bulk endpoint does not delete

The bulk action only changes status. Deleting products is one at a time, and every delete passes through the gate:product.delete hook, where an addon can veto it.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.