Product Endpoints
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
Returns the product catalogue. With no filter you get every type, newest first.
hosting, server, software, ssl or special.type to be special.active or inactive.none when there is none.active or inactive.{id, title}. On an uncategorised product the title is empty.0 means you are on the last one.curl -G 'https://panel.example.com/api/v1/admin/products' \
-H "Authorization: Bearer $API_KEY" \
-d type=hosting \
-d limit=50const 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);{
"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
Opens a skeleton product. Prices, limits and language content go in later with the update endpoint.
hosting, server, software or special. ssl cannot stand alone; it lives inside a special group.special.201. Same shape as the detail endpoint.name was empty.special but no group was given.gate:product.create hook vetoed the operation.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
Returns all of the product's settings, its relations and its content in every language.
{id, name}.active or inactive.visible or invisible.{enabled, items}.null means unlimited.{enabled, days}.{enabled, value}.{enabled, days}.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
Applies the fields you send and leaves everything else as it was.
active or inactive.{enabled, days}.{enabled, value}.{enabled, days}.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.disk, bandwidth, email, database, addons, subdomain, ftp, park, max_email_per_hour. Each is a number or unlimited.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
Changes several products' status in one call. Deleting is not available here.
active or inactive.ids was empty.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
Deletes the product. Its language records and prices go with it.
gate:product.delete hook vetoed the operation.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
Returns the product types this installation offers. The valid values for creating come from here.
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
Returns the fixed and special catalogue groups. Each row hands you a ready type and group pair for creating.
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
Returns the category list flat. You build the tree yourself from the parent ids.
hosting.special.curl -G 'https://panel.example.com/api/v1/admin/products/categories' \
-H "Authorization: Bearer $API_KEY" \
-d type=hostingconst 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
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.
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.
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 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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.